Introduction
In 2026, Next.js dominates fullstack React development thanks to its App Router, which revolutionizes routing, server rendering, and performance. This expert tutorial guides you through building a scalable todos management app, integrating Server Components, Server Actions, Streaming with Suspense, Partial Prerendering, and advanced SEO optimizations.
Why is this crucial? Server Components reduce client JS by 70% on average, Streaming improves TTFB by 50%, and Partial Prerendering blends static/dynamic rendering for perfect Core Web Vitals. You'll learn to structure a production-ready app, manage server state, secure mutations, and scale horizontally.
This guide progresses from foundations to expertise: setup, hybrid components, internal APIs, async actions, conditional rendering. By the end, you'll have a fully functional app ready to bookmark for any lead developer. Ready to boost your Next.js skills? (142 words)
Prerequisites
- Node.js 20+ (with pnpm recommended for speed)
- Advanced knowledge of React 19, TypeScript, and React Server Components
- Familiarity with Vercel or Docker for deployment
- Editor like VS Code with TypeScript/Next.js extensions
- Git for version control
Initializing the Next.js 15+ Project
npx create-next-app@latest todos-app --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd todos-app
pnpm install @types/node
pnpm devThis command creates a Next.js project with App Router enabled, Tailwind for rapid styling, and ESLint for strict linting. The --app option forces App Router (not Pages). --import-alias simplifies absolute imports. Run pnpm dev for hot-reload on http://localhost:3000. Pitfall: Avoid yarn/npm if pnpm isn't installed globally.
App Router Project Structure
The App Router organizes code in src/app/. Each folder is a route segment: app/page.tsx for /, app/todos/page.tsx for /todos. Layouts wrap content (app/layout.tsx), loading.tsx handles Suspense, error.tsx manages errors. Server Components are default (no '"use client"'), executed on the server for zero unnecessary client JS.
Root Layout with SEO Metadata
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Todos App - Next.js Expert 2026',
description: 'App de tâches avec App Router avancé',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="fr">
<body className={inter.className}>{children}</body>
</html>
);
}This root layout defines static SEO metadata for the entire site and uses an optimized Google Font. It wraps all children without client JS. Pitfall: Forget export const metadata; dynamic metas require generateMetadata. Perfect for 100/100 Lighthouse SEO scores.
Homepage with Server Component
import Link from 'next/link';
export default function HomePage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center p-24">
<h1 className="text-6xl font-bold mb-8">Bienvenue sur Todos App</h1>
<p className="text-xl mb-8">Gestion de tâches experte avec Next.js App Router.</p>
<Link
href="/todos"
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
Aller aux Todos
</Link>
</main>
);
}Pure Server Component: 100% server-rendered, zero hydration JS. Uses for smooth client-side navigation. Inline Tailwind classes for speed. Pitfall: No interactivity here (useState forbidden); switch to Client Components for that.
Data Model with Server-Side Zustand-Like Store
import { unstable_cache } from 'next/cache';
type Todo = {
id: string;
text: string;
done: boolean;
};
const db = new Map<string, Todo>();
const getTodos = unstable_cache(
async (): Promise<Todo[]> => {
return Array.from(db.values());
},
['todos'],
{ revalidate: 60 }
);
export async function getAllTodos(): Promise<Todo[]> {
return getTodos();
}
export function createTodo(text: string): Todo {
const id = crypto.randomUUID();
const todo: Todo = { id, text, done: false };
db.set(id, todo);
return todo;
}
export function toggleTodo(id: string): Todo | undefined {
const todo = db.get(id);
if (todo) {
todo.done = !todo.done;
db.set(id, todo);
}
return todo;
}
export function deleteTodo(id: string): void {
db.delete(id);
}Simulates an in-memory DB with unstable_cache for ISR caching (revalidates every 60s). Pure CRUD functions. crypto.randomUUID() for unique IDs. Pitfall: In production, replace with Prisma + PostgreSQL; this cache avoids unnecessary fetches in dev.
Todos Page with Server Fetch
import { getAllTodos } from '@/lib/db';
export default async function TodosPage() {
const todos = await getAllTodos();
return (
<main className="p-8 max-w-2xl mx-auto">
<h1 className="text-4xl font-bold mb-8">Mes Todos</h1>
<ul className="space-y-4">
{todos.map((todo) => (
<li key={todo.id} className="flex justify-between items-center p-4 border rounded">
<span className={todo.done ? 'line-through' : ''}>{todo.text}</span>
<span className="text-sm text-gray-500">ID: {todo.id.slice(0,8)}</span>
</li>
))}
</ul>
</main>
);
}Server Component fetches async data with await. Fresh data on every build/request. Zero client JS downloaded. Pitfall: If fetch errors occur, add a sibling error.tsx. Scalable to 100k+ items with pagination.
Server Actions for Secure Mutations
Server Actions replace API routes for mutations: executed on the server, automatically revalidate cache via revalidatePath. Secure (auto CSRF), no public exposure. Ideal for forms without client fetch.
Server Actions and Client Component Form
'use server';
import { revalidatePath } from 'next/cache';
import { createTodo, toggleTodo, deleteTodo } from '@/lib/db';
export async function addTodo(formData: FormData) {
'use server';
const text = formData.get('text') as string;
createTodo(text);
revalidatePath('/todos');
}
export async function toggle(formData: FormData) {
'use server';
const id = formData.get('id') as string;
toggleTodo(id!);
revalidatePath('/todos');
}
export async function removeTodo(formData: FormData) {
'use server';
const id = formData.get('id') as string;
deleteTodo(id!);
revalidatePath('/todos');
}'use server' at the top for server Actions. FormData enables progressive enhancement (works without JS). revalidatePath updates UI without full reload. Pitfall: Always validate/sanitize formData in production (Zod recommended).
Interactive Todos Page with Actions
import { Suspense } from 'react';
import { getAllTodos } from '@/lib/db';
import TodoList from './TodoList';
import { addTodo } from './actions';
export default async function TodosPage() {
const todos = await getAllTodos();
return (
<main className="p-8 max-w-2xl mx-auto">
<h1 className="text-4xl font-bold mb-8">Mes Todos</h1>
<form action={addTodo} className="mb-8">
<input
name="text"
type="text"
placeholder="Nouvelle tâche..."
className="border p-2 mr-2 w-64"
required
/>
<button type="submit" className="bg-green-500 text-white px-4 py-2 rounded">
Ajouter
</button>
</form>
<Suspense fallback={<p>Chargement...</p>}>
<TodoList initialTodos={todos} />
</Suspense>
</main>
);
}Hybrid: Server initial fetch + Client for interactivity via Actions. streams content. initialTodos props hydrate the client. Pitfall: Server Actions don't leak data; always make them async.
Client Component TodoList with useActionState
'use client';
import { useActionState } from 'react';
import { toggle, removeTodo } from './actions';
type Todo = { id: string; text: string; done: boolean };
interface Props {
initialTodos: Todo[];
}
export default function TodoList({ initialTodos }: Props) {
const [state, toggleAction, isPending] = useActionState(toggle, undefined);
const [stateDel, delAction, isPendingDel] = useActionState(removeTodo, undefined);
return (
<ul className="space-y-4">
{initialTodos.map((todo) => (
<li key={todo.id} className="flex justify-between items-center p-4 border rounded">
<span className={todo.done ? 'line-through' : ''}>{todo.text}</span>
<div>
<form action={toggleAction}>
<input type="hidden" name="id" value={todo.id} />
<button
type="submit"
disabled={isPending}
className="bg-blue-500 text-white px-3 py-1 rounded mr-2"
>
{isPending ? '...' : 'Toggle'}
</button>
</form>
<form action={delAction}>
<input type="hidden" name="id" value={todo.id} />
<button
type="submit"
disabled={isPendingDel}
className="bg-red-500 text-white px-3 py-1 rounded"
>
Supprimer
</button>
</form>
</div>
</li>
))}
</ul>
);
}'use client' for React 19 hooks. useActionState natively handles pending states, replacing useTransition. Optimized: single re-render per action. Pitfall: initialTodos is static; for live updates, add useSWR or WebSockets.
API Route Handler for External Integrations
import { NextRequest, NextResponse } from 'next/server';
import { getAllTodos, createTodo } from '@/lib/db';
export async function GET() {
const todos = await getAllTodos();
return NextResponse.json(todos);
}
export async function POST(request: NextRequest) {
const { text } = await request.json();
if (!text || text.length < 3) {
return NextResponse.json({ error: 'Texte trop court' }, { status: 400 });
}
const todo = createTodo(text);
return NextResponse.json(todo, { status: 201 });
}Route Handler for RESTful API. Standard GET/POST with input validation. Shares DB with the app. Pitfall: No CORS by default; add middleware for production. Ideal for mobile apps or third parties.
Streaming and Partial Prerendering
Streaming with Suspense splits rendering: static shell loads immediately, async content streams in. Partial Prerendering (Next 15+) prerenders static + dynamic on-demand: dynamic = 'force-dynamic' or dynamicParams. Boosts LCP by 40%.
Streaming Example with loading.tsx
export default function Loading() {
return (
<main className="p-8 max-w-2xl mx-auto">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-48 mb-8"></div>
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
</div>
</main>
);
}loading.tsx auto-injected by Suspense. Tailwind skeleton for smooth UX. Streams after shell. Pitfall: No data in loading; keep it pure UI.
Best Practices
- Server Components first: Minimize 'use client' to 10% of code.
- Strategic caching: unstable_cache + revalidatePath/Tag for fresh data.
- Zod for validation: Schemas on actions/forms (e.g., z.string().min(3)).
- Auth middleware: Protect sensitive routes (/todos -> auth check).
- Observability: Integrate Sentry + Vercel Analytics for errors/perf.
Common Errors to Avoid
- Hydration mismatch: Server/Client data diffs (use initialProps).
- Server Actions without 'use server': Runtime error (file top).
- Forgotten Suspense: Rendering blocks (wrap async components).
- Infinite cache: Add revalidate or dynamic='force-dynamic' for user data.
Next Steps
Master Next.js 15+ in depth with our expert Learni trainings. Resources: App Router Docs, Vercel Templates, React 19 Server Components. Deploy on Vercel for free Edge Runtime.