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:
generateStaticParamspre-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 theresetcallback (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/awaitparams in dynamic routes (Next.js 15+ convention)- Server Action for form submission
loading.tsxfor automatic Suspense boundarieserror.tsxfor error handlingnotFound()for 404s
Next: Server Actions | App Router