First App

Build a simple blog with layouts, dynamic routes, data fetching in Server Components, and a Server Action for creating posts. No database needed - we’ll use an in-memory store for simplicity.

Data Layer

// lib/posts.ts
export type Post = {
  slug: string;
  title: string;
  content: string;
  createdAt: Date;
};

// In-memory store (replace with a database later)
const posts: Post[] = [
  {
    slug: "hello-world",
    title: "Hello World",
    content: "This is the first post on the blog.",
    createdAt: new Date("2025-01-01"),
  },
  {
    slug: "nextjs-server-components",
    title: "Server Components Are Great",
    content: "No useEffect needed. Just async/await.",
    createdAt: new Date("2025-01-15"),
  },
];

export async function getPosts(): Promise<Post[]> {
  // Simulate async data source
  return posts.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}

export async function getPost(slug: string): Promise<Post | undefined> {
  return posts.find((p) => p.slug === slug);
}

export async function createPost(title: string, content: string): Promise<Post> {
  const slug = title.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
  const post: Post = { slug, title, content, createdAt: new Date() };
  posts.push(post);
  return post;
}

Root Layout

// app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
import Link from "next/link";

export const metadata: Metadata = {
  title: "My Blog",
  description: "A simple blog built with Next.js",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body className="max-w-2xl mx-auto px-4 py-8">
        <header className="mb-8 border-b pb-4">
          <nav className="flex gap-4">
            <Link href="/" className="font-bold text-xl">My Blog</Link>
            <Link href="/blog" className="text-blue-600 hover:underline">Posts</Link>
            <Link href="/blog/new" className="text-blue-600 hover:underline">Write</Link>
          </nav>
        </header>
        {children}
      </body>
    </html>
  );
}

Blog List Page

// app/blog/page.tsx
import Link from "next/link";
import { getPosts } from "@/lib/posts";

export default async function BlogIndex() {
  const posts = await getPosts();

  return (
    <div>
      <h1 className="text-3xl font-bold mb-6">Blog</h1>
      <ul className="space-y-4">
        {posts.map((post) => (
          <li key={post.slug} className="border-b pb-4">
            <Link href={`/blog/${post.slug}`} className="text-xl text-blue-600 hover:underline">
              {post.title}
            </Link>
            <p className="text-gray-500 text-sm mt-1">
              {post.createdAt.toLocaleDateString()}
            </p>
          </li>
        ))}
      </ul>
    </div>
  );
}

This is a Server Component. getPosts() runs on the server. No API route needed, no useEffect, no loading spinner to manage.

Dynamic Route for Posts

// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getPost, getPosts } from "@/lib/posts";

export async function generateStaticParams() {
  const posts = await getPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) {
    notFound();
  }

  return (
    <article>
      <h1 className="text-3xl font-bold">{post.title}</h1>
      <time className="text-gray-500 text-sm block mt-2">
        {post.createdAt.toLocaleDateString()}
      </time>
      <div className="mt-6 prose">
        <p>{post.content}</p>
      </div>
    </article>
  );
}

Tip: generateStaticParams pre-renders known slugs at build time. New slugs still work at runtime - they’re dynamically rendered on first visit.

Loading State

// app/blog/[slug]/loading.tsx
export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-3/4 mb-4" />
      <div className="h-4 bg-gray-200 rounded w-1/4 mb-8" />
      <div className="space-y-3">
        <div className="h-4 bg-gray-200 rounded" />
        <div className="h-4 bg-gray-200 rounded" />
        <div className="h-4 bg-gray-200 rounded w-5/6" />
      </div>
    </div>
  );
}

This skeleton renders instantly while the page component is loading. It uses React Suspense under the hood.

Error Boundary

// app/blog/[slug]/error.tsx
"use client"; // Error components must be Client Components

export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div className="text-center py-8">
      <h2 className="text-xl font-bold text-red-600">Something went wrong</h2>
      <p className="text-gray-600 mt-2">{error.message}</p>
      <button
        onClick={reset}
        className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
      >
        Try again
      </button>
    </div>
  );
}

Gotcha: Error components must have "use client" because they use the reset callback (an event handler).

Server Action for Creating Posts

// app/blog/new/page.tsx
import { redirect } from "next/navigation";
import { createPost } from "@/lib/posts";

export default function NewPost() {
  async function publish(formData: FormData) {
    "use server";

    const title = formData.get("title") as string;
    const content = formData.get("content") as string;

    if (!title || !content) {
      throw new Error("Title and content are required");
    }

    const post = await createPost(title, content);
    redirect(`/blog/${post.slug}`);
  }

  return (
    <div>
      <h1 className="text-3xl font-bold mb-6">New Post</h1>
      <form action={publish} className="space-y-4">
        <div>
          <label htmlFor="title" className="block text-sm font-medium mb-1">Title</label>
          <input
            id="title"
            name="title"
            type="text"
            required
            className="w-full border rounded px-3 py-2"
          />
        </div>
        <div>
          <label htmlFor="content" className="block text-sm font-medium mb-1">Content</label>
          <textarea
            id="content"
            name="content"
            rows={6}
            required
            className="w-full border rounded px-3 py-2"
          />
        </div>
        <button
          type="submit"
          className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
        >
          Publish
        </button>
      </form>
    </div>
  );
}

This form works without JavaScript enabled (progressive enhancement). The "use server" directive makes publish a Server Action that runs on the server when the form submits.

Not Found Page

// app/blog/[slug]/not-found.tsx
import Link from "next/link";

export default function NotFound() {
  return (
    <div className="text-center py-8">
      <h2 className="text-xl font-bold">Post not found</h2>
      <p className="text-gray-600 mt-2">The post you're looking for doesn't exist.</p>
      <Link href="/blog" className="text-blue-600 hover:underline mt-4 inline-block">
        Back to blog
      </Link>
    </div>
  );
}

What You Built

app/
  layout.tsx              # Nav header, wraps everything
  page.tsx                # Home page
  blog/
    page.tsx              # Post list (Server Component, async data)
    new/
      page.tsx            # Create form (Server Action)
    [slug]/
      page.tsx            # Post detail (dynamic route, async params)
      loading.tsx         # Skeleton loading state
      error.tsx           # Error boundary
      not-found.tsx       # 404 for missing posts

Key patterns used:

  • Server Components for data fetching (no useEffect)
  • async/await params in dynamic routes (Next.js 15+ convention)
  • Server Action for form submission
  • loading.tsx for automatic Suspense boundaries
  • error.tsx for error handling
  • notFound() for 404s

Next: Server Actions | App Router