Skip to content
Learni
View all tutorials
Next.js

How to Master Next.js App Router Expertly in 2026

Lire en français

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

terminal
npx create-next-app@latest todos-app --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd todos-app
pnpm install @types/node
pnpm dev

This 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

src/app/layout.tsx
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

src/app/page.tsx
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

src/lib/db.ts
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

src/app/todos/page.tsx
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

src/app/todos/actions.tsx
'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

src/app/todos/page.tsx
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

src/app/todos/TodoList.tsx
'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

src/app/api/todos/route.ts
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

src/app/todos/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.