webhouse.appwebhouse.appdocs

Complete guide to building a Next.js App Router site with @webhouse/cms — from content reading to deployment.

Overview

This guide walks you through building a complete Next.js site with @webhouse/cms. The CMS handles content storage and editing. Next.js handles rendering and routing.

Step 1: Project setup

bash
# Scaffold a new project
npm create @webhouse/cms my-site

# Or with npx
npx create-@webhouse/cms my-site

# With a specific template
npm create @webhouse/cms my-site -- --template nextjs

Or start from the Next.js boilerplate:

bash
npm create @webhouse/cms my-site -- --template nextjs

Step 2: Content layer

The content layer reads JSON files at build/request time:

typescript
import { readFileSync, readdirSync, existsSync } from 'fs';
import { join } from 'path';

const CONTENT = join(process.cwd(), 'content');

export function getCollection(name: string) {
  const dir = join(CONTENT, name);
  if (!existsSync(dir)) return [];
  return readdirSync(dir)
    .filter(f => f.endsWith('.json'))
    .map(f => JSON.parse(readFileSync(join(dir, f), 'utf-8')))
    .filter(d => d.status === 'published');
}

export function getDocument(collection: string, slug: string) {
  const file = join(CONTENT, collection, `${slug}.json`);
  if (!existsSync(file)) return null;
  return JSON.parse(readFileSync(file, 'utf-8'));
}

Step 3: Root layout

typescript
// app/layout.tsx
export default function RootLayout({ children }) {
  const global = getDocument('global', 'global');
  return (
    <html lang="en">
      <body>
        <nav>{/* render global.data.navLinks */}</nav>
        <main>{children}</main>
        <footer>{global?.data.footerText}</footer>
      </body>
    </html>
  );
}

Step 4: Homepage with blocks

typescript
// app/page.tsx
import { getDocument } from '@/lib/content';

export default function Home() {
  const page = getDocument('pages', 'home');
  if (!page) return <p>Create content/pages/home.json</p>;

  return (
    <div>
      {page.data.sections?.map((block, i) => (
        <BlockRenderer key={i} block={block} />
      ))}
    </div>
  );
}

Step 5: Blog with static generation

typescript
// app/blog/page.tsx
import { getCollection } from '@/lib/content';

export default function BlogPage() {
  const posts = getCollection('posts')
    .sort((a, b) => (b.data.date ?? '').localeCompare(a.data.date ?? ''));

  return (
    <div>
      <h1>Blog</h1>
      {posts.map(post => (
        <a key={post.slug} href={`/blog/${post.slug}`}>
          <h2>{post.data.title}</h2>
          <p>{post.data.excerpt}</p>
        </a>
      ))}
    </div>
  );
}

Individual posts with SEO:

typescript
// app/blog/[slug]/page.tsx
import { getCollection, getDocument } from '@/lib/content';
import { notFound } from 'next/navigation';

export function generateStaticParams() {
  return getCollection('posts').map(d => ({ slug: d.slug }));
}

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const doc = getDocument('posts', slug);
  if (!doc) return {};
  return {
    title: doc.data._seo?.metaTitle ?? doc.data.title,
    description: doc.data._seo?.metaDescription ?? doc.data.excerpt,
  };
}

export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const doc = getDocument('posts', slug);
  if (!doc) notFound();

  return (
    <article>
      <h1>{doc.data.title}</h1>
      {/* Render doc.data.content with react-markdown */}
    </article>
  );
}

Step 6: Richtext rendering

typescript
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';

function ArticleBody({ content }: { content: string }) {
  return (
    <ReactMarkdown remarkPlugins={[remarkGfm]}>
      {content}
    </ReactMarkdown>
  );
}

Never use dangerouslySetInnerHTML with regex-based markdown parsers. Use react-markdown with remark-gfm.

Step 7: Block rendering

typescript
function BlockRenderer({ block }: { block: any }) {
  switch (block._block) {
    case 'hero':
      return (
        <section>
          <h1>{block.tagline}</h1>
          <p>{block.description}</p>
        </section>
      );
    case 'features':
      return (
        <section>
          <h2>{block.title}</h2>
          <div className="grid grid-cols-3 gap-4">
            {block.items?.map((item: any, i: number) => (
              <div key={i}>
                <h3>{item.title}</h3>
                <p>{item.description}</p>
              </div>
            ))}
          </div>
        </section>
      );
    default:
      return null;
  }
}

Step 8: SEO metadata

typescript
export async function generateMetadata({ params }) {
  const doc = getDocument('posts', (await params).slug);
  const seo = doc?.data._seo ?? {};
  return {
    title: seo.metaTitle ?? doc?.data.title,
    description: seo.metaDescription ?? doc?.data.excerpt,
    openGraph: {
      title: seo.metaTitle ?? doc?.data.title,
      description: seo.metaDescription,
      images: seo.ogImage ? [seo.ogImage] : [],
    },
  };
}

Step 9: i18n (optional)

typescript
export default defineConfig({
  defaultLocale: 'en',
  locales: ['en', 'da'],
  collections: [
    defineCollection({
      name: 'posts',
      sourceLocale: 'en',
      locales: ['en', 'da'],
      translatable: true,
      fields: [
        { name: 'title', type: 'text', required: true },
        { name: 'content', type: 'richtext' },
      ],
    }),
  ],
});

Step 10: Deployment

dockerfile
FROM node:22-alpine AS builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build

FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
COPY --from=builder /app/content ./content
CMD ["node", "server.js"]

The build pipeline integration

When you run next build, Next.js:

  1. Reads all content from content/ via your loader functions
  2. Pre-renders all pages via generateStaticParams
  3. Generates SEO metadata via generateMetadata
  4. Outputs to .next/ (or .next/standalone for Docker)

The CMS build pipeline (npx cms build) generates additional files:

  • sitemap.xml, robots.txt, feed.xml
  • llms.txt, llms-full.txt for AI discovery
  • Per-page .md files

For a Next.js site, you typically use Next.js's own app/sitemap.ts and app/robots.ts instead of the CMS build pipeline.

Key patterns

  1. Server Components by default — all content reads happen server-side
  2. "use client" only where needed — theme toggle, search, markdown renderer
  3. generateStaticParams — pre-generate all pages at build time
  4. generateMetadata — SEO from CMS _seo fields with fallbacks
  5. Never hardcode content — everything from CMS JSON files
  6. Filter by published — always check status === "published"
Tags:Next.jsFrameworksDeploy: Vercel
Previous
Guide to build.ts — Static Site Generation
Next
Next.js SEO Helpers
JSON API →Edit on GitHub →