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.
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.
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.
// lib/posts.ts import 'server-only'; // ← this module never runs in the browser import { cache } from 'react'; import fs from 'fs'; import path from 'path'; import matter from 'gray-matter';
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.
return ( <div> <section className="mb-16"> <h1 className="text-4xl font-display font-bold mb-4"> Engineering in the open </h1> <p className="text-xl text-zinc-400"> Deep dives into systems, performance, and the decisions that matter in production. </p> </section>
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 uses the URL-as-state pattern for filtering — the Server Component reads searchParams, the Client Component manages the URL:
// src/app/blog/page.tsx
import { Suspense } from 'react'; import { getAllPosts } from '@/lib/posts'; import PostCard from '@/components/PostCard'; import CategoryFilter from '@/components/CategoryFilter';
// Pre-render all known posts at build time export async function generateStaticParams() { const posts = await getAllPosts(); return posts.map(post => ({ slug: post.slug })); }
// Generate metadata per post export async function generateMetadata({ params }: PageProps): Promise<Metadata> { const { slug } = await params; const post = await getPost(slug); // React cache() deduplicates this with the call below
export default function PostNotFound() { return ( <div className="text-center py-24"> <h2 className="text-2xl font-semibold mb-2">Post not found</h2> <p className="text-zinc-400 mb-6"> This post doesn't exist or has been removed. </p> <Link href="/blog" className="text-zinc-400 hover:text-white transition-colors"> ← Back to all posts </Link> </div> ); }
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.
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.
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.
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
npm run build npm run start # runs the standalone Node.js server on port 3000
For containerised deployment, enable standalone output in next.config.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:
FROM node:20-alpine AS runner WORKDIR /app COPY .next/standalone ./ COPY .next/static ./.next/static COPY public ./public EXPOSE 3000 CMD ["node", "server.js"]
ISR works the same on self-hosted — Next.js manages the regeneration internally without Vercel infrastructure.
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.
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.
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
// next.config.ts — if you must do static export const config: NextConfig = { output: 'export', images: { unoptimized: true, // required for static export }, };
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.
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.
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).
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
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.