Back/Module F-8 Your First Real Next.js Application
Module F-8·28 min read

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

bash
npx create-next-app@latest my-blog --typescript --eslint --tailwind --src-dir --app --turbopack --import-alias "@/*" cd my-blog npm install

The project structure we'll work toward:

src/
├── app/
│   ├── layout.tsx              ← Root layout with font, nav
│   ├── page.tsx                ← Homepage
│   ├── blog/
│   │   ├── page.tsx            ← Blog index with filtering
│   │   ├── loading.tsx         ← Blog index skeleton
│   │   └── [slug]/
│   │       ├── page.tsx        ← Individual post
│   │       └── not-found.tsx   ← 404 for unknown slugs
│   └── api/
│       └── rss/
│           └── route.ts        ← RSS feed
├── components/
│   ├── PostCard.tsx
│   ├── CategoryFilter.tsx      ← Client Component for URL state
│   └── Nav.tsx
└── lib/
    ├── posts.ts                ← Data fetching utilities
    └── types.ts

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
// lib/types.ts export interface Post { slug: string; title: string; excerpt: string; content: string; publishedAt: string; category: string; author: string; coverImage?: string; }
ts
// 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'; const postsDirectory = path.join(process.cwd(), 'content/posts'); // cache() memoises per request — no double reads for the same slug export const 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 { return null; } }); export const getAllPosts = cache(async (): Promise<Post[]> => { const files = fs.readdirSync(postsDirectory).filter(f => f.endsWith('.md')); const posts = await Promise.all( files.map(file => getPost(file.replace('.md', ''))) ); return posts .filter((p): p is Post => p !== null) .sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()); }); export const getPostsByCategory = cache(async (category: string): Promise<Post[]> => { const posts = await getAllPosts(); return posts.filter(p => p.category === category); });

Install gray-matter for Markdown frontmatter parsing:

bash
npm install gray-matter

The Root Layout

The root layout sets up global font, navigation, and HTML structure:

tsx
// src/app/layout.tsx import type { Metadata } from 'next'; import { Inter, Sora } from 'next/font/google'; import Nav from '@/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', }); export const metadata: Metadata = { title: { template: '%s | Dev Blog', default: 'Dev Blog' }, description: 'Engineering insights and technical deep-dives', metadataBase: new URL('https://yourdomain.com'), openGraph: { type: 'website', siteName: 'Dev Blog', }, }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en" className={`${inter.variable} ${sora.variable}`}> <body className="bg-zinc-950 text-zinc-100 min-h-screen font-sans antialiased"> <Nav /> <main className="max-w-4xl mx-auto px-4 py-12"> {children} </main> </body> </html> ); }

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
// src/app/page.tsx import Link from 'next/link'; import Image from 'next/image'; import { getAllPosts } from '@/lib/posts'; import PostCard from '@/components/PostCard'; export default async function HomePage() { const posts = await getAllPosts(); const recentPosts = posts.slice(0, 5); 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> <section> <h2 className="text-lg font-semibold text-zinc-400 mb-6 uppercase tracking-wider"> Recent Posts </h2> <div className="space-y-1"> {recentPosts.map(post => ( <PostCard key={post.slug} post={post} /> ))} </div> {posts.length > 5 && ( <Link href="/blog" className="mt-8 inline-block text-zinc-400 hover:text-white transition-colors" > View all posts → </Link> )} </section> </div> ); }

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
// src/app/blog/page.tsx import { Suspense } from 'react'; import { getAllPosts } from '@/lib/posts'; import PostCard from '@/components/PostCard'; import CategoryFilter from '@/components/CategoryFilter'; interface PageProps { searchParams: Promise<{ category?: string }>; } export const metadata = { title: 'Blog', description: 'All engineering posts', }; export default async function BlogPage({ searchParams }: PageProps) { const { category } = await searchParams; const posts = await getAllPosts(); const filteredPosts = category ? posts.filter(p => p.category === category) : posts; const categories = [...new Set(posts.map(p => p.category))]; return ( <div> <h1 className="text-3xl font-display font-bold mb-8">All Posts</h1> <Suspense fallback={<div className="h-10 animate-pulse rounded bg-zinc-800 mb-6" />}> <CategoryFilter categories={categories} activeCategory={category} /> </Suspense> <div className="space-y-1 mt-6"> {filteredPosts.length === 0 ? ( <p className="text-zinc-400">No posts in this category yet.</p> ) : ( filteredPosts.map(post => <PostCard key={post.slug} post={post} />) )} </div> </div> ); }
tsx
// src/app/blog/loading.tsx export default function BlogLoading() { return ( <div> <div className="h-9 w-48 animate-pulse rounded bg-zinc-800 mb-8" /> <div className="h-10 animate-pulse rounded bg-zinc-800 mb-6" /> {[...Array(5)].map((_, i) => ( <div key={i} className="h-16 animate-pulse rounded bg-zinc-800 mb-1" /> ))} </div> ); }
tsx
// src/components/CategoryFilter.tsx 'use client'; import { useRouter, useSearchParams, usePathname } from 'next/navigation'; interface CategoryFilterProps { categories: string[]; activeCategory?: string; } export default function CategoryFilter({ categories, activeCategory }: CategoryFilterProps) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); function setCategory(category: string | null) { const params = new URLSearchParams(searchParams.toString()); if (category) { params.set('category', category); } else { params.delete('category'); } router.push(`${pathname}?${params.toString()}`); } return ( <div className="flex flex-wrap gap-2 mb-6"> <button onClick={() => setCategory(null)} className={!activeCategory ? 'bg-white text-black px-3 py-1 rounded text-sm' : 'text-zinc-400 px-3 py-1 rounded text-sm hover:text-white'} > All </button> {categories.map(cat => ( <button key={cat} onClick={() => setCategory(cat)} className={activeCategory === cat ? 'bg-white text-black px-3 py-1 rounded text-sm' : 'text-zinc-400 px-3 py-1 rounded text-sm hover:text-white'} > {cat} </button> ))} </div> ); }

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
// src/app/blog/[slug]/page.tsx import { notFound } from 'next/navigation'; import type { Metadata } from 'next'; import { getPost, getAllPosts } from '@/lib/posts'; interface PageProps { params: Promise<{ slug: string }>; } // 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 if (!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, }, }; } export default async function PostPage({ params }: PageProps) { const { slug } = await params; const post = await getPost(slug); // cache() means no second filesystem read if (!post) notFound(); return ( <article className="prose prose-invert max-w-none"> <header className="mb-8 not-prose"> <div className="text-sm text-zinc-400 mb-2"> <time dateTime={post.publishedAt}> {new Date(post.publishedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', })} </time> <span className="mx-2">·</span> <span>{post.category}</span> </div> <h1 className="text-3xl font-display font-bold">{post.title}</h1> </header> <div dangerouslySetInnerHTML={{ __html: post.content }} /> </article> ); }
tsx
// src/app/blog/[slug]/not-found.tsx import Link from 'next/link'; 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.


The RSS Feed — A Route Handler

ts
// src/app/api/rss/route.ts import { getAllPosts } from '@/lib/posts'; export const revalidate = 3600; // regenerate RSS feed hourly export async function GET() { const posts = await getAllPosts(); const siteUrl = 'https://yourdomain.com'; const rss = `<?xml version="1.0" encoding="UTF-8" ?> <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> <channel> <title>Dev Blog</title> <link>${siteUrl}</link> <description>Engineering insights and technical deep-dives</description> <language>en-us</language> <atom:link href="${siteUrl}/api/rss" rel="self" type="application/rss+xml" /> ${posts.slice(0, 20).map(post => ` <item> <title><![CDATA[${post.title}]]></title> <link>${siteUrl}/blog/${post.slug}</link> <guid>${siteUrl}/blog/${post.slug}</guid> <pubDate>${new Date(post.publishedAt).toUTCString()}</pubDate> <description><![CDATA[${post.excerpt}]]></description> </item>`).join('')} </channel> </rss>`; return new Response(rss, { headers: { 'Content-Type': 'application/xml; charset=utf-8', 'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400', }, }); }

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:

Route (app)                              Size     First Load JS
┌ ○ /                                    3.1 kB         84.2 kB
├ ○ /blog                               1.8 kB         82.9 kB  ← Wait...
├ ● /blog/[slug]                         1.2 kB         82.3 kB
└ ○ /api/rss                            0 B            0 B

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
npm install -g vercel vercel

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
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:

ts
const config: NextConfig = { output: 'standalone', };

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
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.


What You've Now Built

This application uses every Foundation concept:

ConceptWhere it appears
Server Componentspage.tsx files, PostCard, lib/posts.ts
Client ComponentCategoryFilter.tsx (uses hooks + URL state)
File-system routingapp/ directory structure
Dynamic segment/blog/[slug]
generateStaticParamsPost page — pre-renders at build time
loading.tsxBlog index skeleton
not-found.tsxPost page 404
generateMetadataPost page — per-post OG tags
Route Handler/api/rss — XML response with ISR
next/fontRoot layout — Google Fonts
react cache()lib/posts.ts — deduped filesystem reads
Suspense boundaryBlog index wrapping CategoryFilter
URL-as-stateCategory 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:

  1. Use output: 'export' with unoptimized: true in next.config.ts — images are served as-is, no optimisation
  2. Use a third-party image CDN (Cloudinary, imgix) and configure a custom loader
  3. Don't use output: 'export' — run the Node.js server
ts
// next.config.ts — if you must do static export const config: NextConfig = { output: 'export', images: { unoptimized: true, // required for static export }, };

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:

FeatureWorks 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.

Discussion