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
# 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 nextjsOr start from the Next.js boilerplate:
npm create @webhouse/cms my-site -- --template nextjsStep 2: Content layer
The content layer reads JSON files at build/request time:
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
// 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
// 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
// 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:
// 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
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
function ArticleBody({ content }: { content: string }) {
return (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{content}
</ReactMarkdown>
);
}Never use
dangerouslySetInnerHTMLwith regex-based markdown parsers. Usereact-markdownwithremark-gfm.
Step 7: Block rendering
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
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)
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
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:
- Reads all content from
content/via your loader functions - Pre-renders all pages via
generateStaticParams - Generates SEO metadata via
generateMetadata - Outputs to
.next/(or.next/standalonefor Docker)
The CMS build pipeline (npx cms build) generates additional files:
sitemap.xml,robots.txt,feed.xmlllms.txt,llms-full.txtfor AI discovery- Per-page
.mdfiles
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
- Server Components by default — all content reads happen server-side
"use client"only where needed — theme toggle, search, markdown renderergenerateStaticParams— pre-generate all pages at build timegenerateMetadata— SEO from CMS_seofields with fallbacks- Never hardcode content — everything from CMS JSON files
- Filter by published — always check
status === "published"