Building a content site end-to-end — layouts, dynamic routes, data fetching, generateMetadata for SEO, static vs dynamic rendering decisions, and what silently breaks when you deploy outside Vercel.
Who this is for: Developers who have completed F-1 through F-7 and want to see everything connect in a complete, working application before moving into the Practitioner phase. This is a build module — we're constructing a content site end-to-end, making real decisions along the way, and deploying it. No toy examples; a real architecture you'd actually ship.
What We're Building
A content site for a technical blog — the kind of thing you'd build for a personal site, a company engineering blog, or a documentation hub. It has:
A homepage with recent posts
A blog index page with category filtering
Individual blog post pages
An RSS feed endpoint
Full SEO metadata including Open Graph images
Static generation for published posts
Incremental static regeneration for the post listing
This covers the full Foundation toolkit: Server Components, file-system routing, dynamic params, data fetching, built-in components, the metadata API, a Route Handler, and deployment. After building this, you have a real reference architecture to adapt.
Project Setup
bash
The project structure we'll work toward:
text
The Data Layer
For this example we'll use a file-system based data layer — posts as Markdown files — which is a common real-world pattern for personal blogs and documentation sites.
ts
ts
Install gray-matter for Markdown frontmatter parsing:
bash
The Root Layout
The root layout sets up global font, navigation, and HTML structure:
tsx
metadataBase tells Next.js the base URL for resolving relative URLs in Open Graph images and other metadata. Set it to your production domain. In development it defaults to localhost:3000.
The Homepage
tsx
This page is statically generated — it fetches data at build time and produces a static HTML file. Because getAllPosts reads from the filesystem with no external dependencies, it runs at build time and the output never changes until you redeploy.
The Blog Index — With Category Filtering
The blog index uses the URL-as-state pattern for filtering — the Server Component reads searchParams, the Client Component manages the URL:
tsx
tsx
tsx
The blog index is a dynamic page (it reads searchParams). But getAllPosts still runs on the server and the result isn't fetched on the client.
The Post Page — Static Generation with Dynamic Metadata
tsx
tsx
Two important things here: generateStaticParams pre-renders every post at build time. generateMetadata and the page component both call getPost(slug) — but because getPost is wrapped in React's cache(), the filesystem is only read once per request despite the two calls.
The RSS Feed — A Route Handler
ts
export const revalidate = 3600 sets ISR at the Route Handler level — Next.js caches the response for one hour and regenerates in the background when it goes stale. RSS readers typically poll every hour, so a one-hour cache is both fresh enough and cheap enough.
Reading the Build Output
After npm run build, look for this kind of output:
text
Notice /blog shows ○ (static) even though it reads searchParams. This is because searchParams itself doesn't trigger dynamic rendering at the page level in the build output — the page shell is static. The filter functionality kicks in at runtime when the URL has a query parameter. This is correct behaviour.
If you see λ on a page you expected to be static, look for dynamic function calls: accessing cookies(), headers(), or passing the request to something that reads them.
The Deployment Decision
Vercel (recommended for getting started)
bash
Vercel detects Next.js automatically, configures everything, and gives you a live URL in under a minute. ISR, streaming, and Edge functions all work out of the box. Free tier handles the load of a personal blog without issue.
Self-hosting on any Node.js host
bash
For containerised deployment, enable standalone output in next.config.ts:
ts
This generates a minimal /.next/standalone folder with just the Node.js server and its dependencies — no node_modules bloat. Build the Docker image from that:
dockerfile
ISR works the same on self-hosted — Next.js manages the regeneration internally without Vercel infrastructure.
What You've Now Built
This application uses every Foundation concept:
Concept
Where it appears
Server Components
page.tsx files, PostCard, lib/posts.ts
Client Component
CategoryFilter.tsx (uses hooks + URL state)
File-system routing
app/ directory structure
Dynamic segment
/blog/[slug]
generateStaticParams
Post page — pre-renders at build time
loading.tsx
Blog index skeleton
not-found.tsx
Post page 404
generateMetadata
Post page — per-post OG tags
Route Handler
/api/rss — XML response with ISR
next/font
Root layout — Google Fonts
react cache()
lib/posts.ts — deduped filesystem reads
Suspense boundary
Blog index wrapping CategoryFilter
URL-as-state
Category filter
Where the Practitioner Phase Begins
You've completed the Foundation phase. You can build real Next.js applications. You understand the rendering spectrum, the Server/Client boundary, data fetching patterns, dynamic routing, the built-in components, Route Handlers, and deployment.
The Practitioner phase assumes this foundation and builds on it for production applications at scale. P-1 goes deep on caching — not just what the options are (you saw those in F-4) but how to architect your data access layer around Next.js's cache model to maximise performance while maintaining data freshness. P-2 covers Server Actions properly — how they're compiled, what the security implications are, and how to use useActionState and optimistic UI to build forms that feel instant.
The gap between Foundation and Practitioner is the gap between "this works" and "this is ready for production traffic." P-1 is where that gap starts to close.
What Silently Breaks Outside Vercel
The five-minute Vercel deploy is real. The problem is that "it works on Vercel" creates a false ceiling — you ship, the app works, and then six months later someone asks about self-hosting, AWS, or Docker and discovers that several features they've been relying on are Vercel-only.
Know these before you build on them.
next/image Optimisation Requires a Running Server
next/image proxies image optimisation through /_next/image?url=...&w=...&q=.... That endpoint exists on the Next.js server. If you deploy with output: 'export' (static HTML export), that server does not exist. Every <Image> in your app breaks with a 404.
What happens: The browser requests /_next/image?url=.... There's no server to respond. The image doesn't load.
Fix options:
Use output: 'export' with unoptimized: true in next.config.ts — images are served as-is, no optimisation
Use a third-party image CDN (Cloudinary, imgix) and configure a custom loader
Don't use output: 'export' — run the Node.js server
ts
Server Actions Don't Exist in Static Exports
output: 'export' generates a folder of .html files. There is no server. Server Actions POST to /_next/action — that endpoint does not exist in a static export.
What happens: Calling a Server Action throws a network error. Forms that use Server Actions silently fail.
Fix: Move mutations to a separate API — a Route Handler on a separate server, or a completely separate backend service. Static exports cannot have server-side mutation logic.
ISR Needs a Cache Handler on Multi-Instance Deployments
On Vercel, ISR just works. On a self-hosted Node.js server or Kubernetes, ISR uses the local filesystem as a cache. If you have two instances (two pods, two EC2 instances), each has its own filesystem. revalidatePath('/products') on Pod A doesn't affect Pod B. Users get inconsistent data depending on which pod serves their request.
What happens: After a revalidation, 50% of users still see stale content (those hitting the other pod).
Fix: Set up a shared Redis cache handler so all pods share one cache. This is covered in depth in A-3 and A-14.
after() Needs Adapter Support
The after() API — which runs callbacks after the response is sent — requires the runtime to support deferred execution. Vercel supports it natively. On self-hosted Node.js, it works because the process stays alive. On some serverless platforms that terminate the function immediately after the response is sent, after() callbacks are silently dropped.
What breaks: Analytics events, audit log writes, and cache warming that you've put in after() callbacks stop firing.
Fix: Verify your platform supports waitUntil semantics before relying on after(). For Cloudflare Workers, use ctx.waitUntil(). For bare serverless, fire-and-forget is unreliable — use a proper job queue (BullMQ, Inngest).
Edge Config, request.geo, and Vercel Analytics Are Vercel-Only
Vercel Edge Config (sub-millisecond key-value store), request.geo (geo data on the request object in Middleware), and @vercel/analytics are Vercel-specific. They don't exist on self-hosted deployments.
What breaks: Any Middleware logic that reads request.geo returns undefined. Any Edge Config reads throw. Analytics events are silently dropped.
Fix alternatives:
request.geo → Read CF-IPCountry header (Cloudflare) or X-Vercel-IP-Country equivalent from your CDN
Edge Config → Redis with a read-through cache, or hardcoded config for simpler cases
Vercel Analytics → Plausible, PostHog, or self-hosted Umami
The Static Export Feature Graveyard
Full list of what output: 'export' removes:
Feature
Works in export?
Server Components (read-only)
✅ (rendered at build time)
Client Components
✅
next/image optimisation
❌ (use unoptimized: true)
Server Actions
❌
Route Handlers
❌
ISR / revalidate
❌
Middleware
❌
cookies() / headers()
❌
Dynamic routes without generateStaticParams
❌
i18n routing
❌
If your application needs any of these, you cannot use output: 'export'. Run the Node.js server.
Knowledge Check
When inspecting the Next.js build output, what does the ○ (Static) symbol signify for a route like /blog that uses searchParams?
What happens to next/image components when you deploy a Next.js application using output: 'export' (static HTML export) without additional configuration?
Why might Incremental Static Regeneration (ISR) lead to inconsistent data across users on a multi-instance self-hosted deployment (e.g., Kubernetes)?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
// lib/posts.tsimport'server-only';// ← this module never runs in the browserimport{ cache }from'react';import fs from'fs';import path from'path';import matter from'gray-matter';const postsDirectory = path.join(process.cwd(),'content/posts');// cache() memoises per request — no double reads for the same slugexportconst getPost =cache(async(slug:string):Promise<Post |null>=>{try{const filePath = path.join(postsDirectory,`${slug}.md`);const raw = fs.readFileSync(filePath,'utf-8');const{ data, content }=matter(raw);return{ slug, title: data.title, excerpt: data.excerpt, content, publishedAt: data.publishedAt, category: data.category, author: data.author, coverImage: data.coverImage,};}catch{returnnull;}});exportconst getAllPosts =cache(async():Promise<Post[]>=>{const files = fs.readdirSync(postsDirectory).filter(f => f.endsWith('.md'));const posts =awaitPromise.all( files.map(file =>getPost(file.replace('.md',''))));return posts
.filter((p): p is Post => p !==null).sort((a, b)=>newDate(b.publishedAt).getTime()-newDate(a.publishedAt).getTime());});exportconst getPostsByCategory =cache(async(category:string):Promise<Post[]>=>{const posts =awaitgetAllPosts();return posts.filter(p => p.category === category);});
npminstall gray-matter
// src/app/layout.tsximporttype{Metadata}from'next';import{Inter,Sora}from'next/font/google';importNavfrom'@/components/Nav';import'./globals.css';const inter =Inter({ subsets:['latin'], variable:'--font-inter'});const sora =Sora({ subsets:['latin'], weight:['400','600','700'], variable:'--font-sora',});exportconst metadata:Metadata={ title:{ template:'%s | Dev Blog',default:'Dev Blog'}, description:'Engineering insights and technical deep-dives', metadataBase:newURL('https://yourdomain.com'), openGraph:{ type:'website', siteName:'Dev Blog',},};exportdefaultfunctionRootLayout({ children }:{ children:React.ReactNode}){return(<htmllang="en"className={`${inter.variable}${sora.variable}`}><bodyclassName="bg-zinc-950 text-zinc-100 min-h-screen font-sans antialiased"><Nav/><mainclassName="max-w-4xl mx-auto px-4 py-12">{children}</main></body></html>);}
// src/app/page.tsximportLinkfrom'next/link';importImagefrom'next/image';import{ getAllPosts }from'@/lib/posts';importPostCardfrom'@/components/PostCard';exportdefaultasyncfunctionHomePage(){const posts =awaitgetAllPosts();const recentPosts = posts.slice(0,5);return(<div><sectionclassName="mb-16"><h1className="text-4xl font-display font-bold mb-4"> Engineering in the open
</h1><pclassName="text-xl text-zinc-400"> Deep dives into systems, performance, and the decisions that matter in production.
</p></section><section><h2className="text-lg font-semibold text-zinc-400 mb-6 uppercase tracking-wider"> Recent Posts
</h2><divclassName="space-y-1">{recentPosts.map(post =>(<PostCardkey={post.slug}post={post}/>))}</div>{posts.length>5&&(<Linkhref="/blog"className="mt-8 inline-block text-zinc-400 hover:text-white transition-colors"> View all posts →
</Link>)}</section></div>);}
// src/app/blog/[slug]/page.tsximport{ notFound }from'next/navigation';importtype{Metadata}from'next';import{ getPost, getAllPosts }from'@/lib/posts';interfacePageProps{ params:Promise<{ slug:string}>;}// Pre-render all known posts at build timeexportasyncfunctiongenerateStaticParams(){const posts =awaitgetAllPosts();return posts.map(post =>({ slug: post.slug}));}// Generate metadata per postexportasyncfunctiongenerateMetadata({ params }:PageProps):Promise<Metadata>{const{ slug }=await params;const post =awaitgetPost(slug);// React cache() deduplicates this with the call belowif(!post)return{ title:'Post Not Found'};return{ title: post.title, description: post.excerpt, openGraph:{ title: post.title, description: post.excerpt, type:'article', publishedTime: post.publishedAt, authors:[post.author], images: post.coverImage?[{ url: post.coverImage}]:[],}, twitter:{ card:'summary_large_image', title: post.title, description: post.excerpt,},};}exportdefaultasyncfunctionPostPage({ params }:PageProps){const{ slug }=await params;const post =awaitgetPost(slug);// cache() means no second filesystem readif(!post)notFound();return(<articleclassName="prose prose-invert max-w-none"><headerclassName="mb-8 not-prose"><divclassName="text-sm text-zinc-400 mb-2"><timedateTime={post.publishedAt}>{newDate(post.publishedAt).toLocaleDateString('en-US',{ year:'numeric', month:'long', day:'numeric',})}</time><spanclassName="mx-2">·</span><span>{post.category}</span></div><h1className="text-3xl font-display font-bold">{post.title}</h1></header><divdangerouslySetInnerHTML={{ __html: post.content}}/></article>);}
// src/app/blog/[slug]/not-found.tsximportLinkfrom'next/link';exportdefaultfunctionPostNotFound(){return(<divclassName="text-center py-24"><h2className="text-2xl font-semibold mb-2">Post not found</h2><pclassName="text-zinc-400 mb-6"> This post doesn't exist or has been removed.
</p><Linkhref="/blog"className="text-zinc-400 hover:text-white transition-colors"> ← Back to all posts
</Link></div>);}
FROM node:20-alpine AS runnerWORKDIR /appCOPY .next/standalone ./COPY .next/static ./.next/staticCOPY public ./publicEXPOSE 3000CMD ["node", "server.js"]
// next.config.ts — if you must do static exportconst config: NextConfig ={ output:'export', images:{ unoptimized:true,// required for static export},};