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.
F-8 — Your First Real Next.js Application
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
The project structure we'll work toward:
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.
Install gray-matter for Markdown frontmatter parsing:
The Root Layout
The root layout sets up global font, navigation, and HTML structure:
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
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:
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
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
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:
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)
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
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:
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'withunoptimized: truein 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
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→ ReadCF-IPCountryheader (Cloudflare) orX-Vercel-IP-Countryequivalent 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.
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.
Sign in & RegisterDiscussion
0Join the discussion