Module F-8·17 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.

Written by Jatin Jain Saraf · Architect of SupraScan

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

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.

// lib/types.ts
 
export interface Post {
  slug: string;
  title: string;
  excerpt: string;
  content: string;
  publishedAt: string;
  category: string;
  author: string;
  coverImage?: string;
}
// 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:

npm install gray-matter

The Root Layout

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

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

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

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.