Next.js App Router Practical Guide

intermediate | 70 min read | 2024.12.17

What You’ll Learn in This Tutorial

✓ App Router basic structure
✓ Server Components and Client Components
✓ Data fetching
✓ Routing and layouts
✓ Loading and error handling
✓ Server Actions

Prerequisites

  • Basic React knowledge
  • Basic TypeScript knowledge
  • Node.js 18 or higher installed

Project Setup

# Create project
npx create-next-app@latest my-app --typescript --tailwind --eslint --app

cd my-app
npm run dev

Directory Structure

my-app/
├── app/
│   ├── layout.tsx      # Root layout
│   ├── page.tsx        # Home page
│   ├── globals.css     # Global styles
│   └── ...
├── components/         # Shared components
├── lib/               # Utility functions
└── public/            # Static files

Step 1: App Router Basics

File-Based Routing

app/
├── page.tsx                    # / (home)
├── about/
│   └── page.tsx               # /about
├── blog/
│   ├── page.tsx               # /blog
│   └── [slug]/
│       └── page.tsx           # /blog/:slug
├── products/
│   ├── page.tsx               # /products
│   └── [...categories]/
│       └── page.tsx           # /products/* (catch-all)
└── (marketing)/
    ├── pricing/
    │   └── page.tsx           # /pricing
    └── contact/
        └── page.tsx           # /contact

Basic Page Component

// app/page.tsx
export default function HomePage() {
  return (
    <main className="container mx-auto p-4">
      <h1 className="text-4xl font-bold">Home</h1>
      <p className="mt-4">Welcome to Next.js App Router!</p>
    </main>
  );
}

Dynamic Routing

// app/blog/[slug]/page.tsx
interface Props {
  params: Promise<{ slug: string }>;
}

export default async function BlogPost({ params }: Props) {
  const { slug } = await params;

  return (
    <article>
      <h1>Blog Post: {slug}</h1>
    </article>
  );
}

// Generate static params
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then(res => res.json());

  return posts.map((post: { slug: string }) => ({
    slug: post.slug,
  }));
}

Step 2: Layouts and Templates

Root Layout

// 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: {
    default: 'My App',
    template: '%s | My App',
  },
  description: 'Next.js App Router Demo',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <header className="bg-gray-800 text-white p-4">
          <nav className="container mx-auto flex gap-4">
            <a href="/">Home</a>
            <a href="/blog">Blog</a>
            <a href="/about">About</a>
          </nav>
        </header>
        {children}
        <footer className="bg-gray-100 p-4 mt-8">
          <p className="text-center">© 2024 My App</p>
        </footer>
      </body>
    </html>
  );
}

Nested Layout

// app/blog/layout.tsx
export default function BlogLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="container mx-auto flex gap-8 p-4">
      <aside className="w-64">
        <h2 className="font-bold mb-4">Categories</h2>
        <ul className="space-y-2">
          <li><a href="/blog?category=tech">Technology</a></li>
          <li><a href="/blog?category=design">Design</a></li>
          <li><a href="/blog?category=business">Business</a></li>
        </ul>
      </aside>
      <main className="flex-1">{children}</main>
    </div>
  );
}

Route Groups

// app/(auth)/layout.tsx
// (auth) doesn't affect the URL
export default function AuthLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-100">
      <div className="bg-white p-8 rounded-lg shadow-md w-96">
        {children}
      </div>
    </div>
  );
}

// app/(auth)/login/page.tsx → /login
// app/(auth)/register/page.tsx → /register

Step 3: Server Components and Client Components

Server Components (Default)

// app/products/page.tsx
// This is a Server Component (default)
async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    cache: 'force-cache', // Static generation
  });
  return res.json();
}

export default async function ProductsPage() {
  const products = await getProducts();

  return (
    <div className="grid grid-cols-3 gap-4">
      {products.map((product: { id: number; name: string; price: number }) => (
        <div key={product.id} className="border p-4 rounded">
          <h2>{product.name}</h2>
          <p>${product.price.toLocaleString()}</p>
        </div>
      ))}
    </div>
  );
}

Client Components

// components/Counter.tsx
'use client'; // Specify as Client Component

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div className="flex items-center gap-4">
      <button
        onClick={() => setCount(count - 1)}
        className="px-4 py-2 bg-gray-200 rounded"
      >
        -
      </button>
      <span className="text-2xl">{count}</span>
      <button
        onClick={() => setCount(count + 1)}
        className="px-4 py-2 bg-blue-500 text-white rounded"
      >
        +
      </button>
    </div>
  );
}

Usage Patterns

// app/dashboard/page.tsx (Server Component)
import Counter from '@/components/Counter';
import UserProfile from '@/components/UserProfile';

async function getUserData() {
  const res = await fetch('https://api.example.com/user');
  return res.json();
}

export default async function DashboardPage() {
  // Fetch data server-side
  const user = await getUserData();

  return (
    <div>
      {/* Pass data via Server Component */}
      <UserProfile user={user} />

      {/* Client Component for interactivity */}
      <Counter />
    </div>
  );
}

Step 4: Data Fetching

Parallel Data Fetching

// app/dashboard/page.tsx
async function getUser() {
  const res = await fetch('https://api.example.com/user');
  return res.json();
}

async function getNotifications() {
  const res = await fetch('https://api.example.com/notifications');
  return res.json();
}

async function getStats() {
  const res = await fetch('https://api.example.com/stats');
  return res.json();
}

export default async function DashboardPage() {
  // Fetch data in parallel
  const [user, notifications, stats] = await Promise.all([
    getUser(),
    getNotifications(),
    getStats(),
  ]);

  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      <p>Notifications: {notifications.length}</p>
      <p>Revenue this month: ${stats.revenue.toLocaleString()}</p>
    </div>
  );
}

Cache Control

// Static data (fetched at build time)
const staticData = await fetch('https://api.example.com/data', {
  cache: 'force-cache',
});

// Dynamic data (fetched every time)
const dynamicData = await fetch('https://api.example.com/data', {
  cache: 'no-store',
});

// Time-based revalidation
const revalidatedData = await fetch('https://api.example.com/data', {
  next: { revalidate: 3600 }, // 1 hour
});

// Tag-based revalidation
const taggedData = await fetch('https://api.example.com/products', {
  next: { tags: ['products'] },
});

// revalidateTag('products') to revalidate

Progressive Rendering with Suspense

// app/page.tsx
import { Suspense } from 'react';
import RecommendedProducts from '@/components/RecommendedProducts';
import LatestNews from '@/components/LatestNews';

export default function HomePage() {
  return (
    <div>
      <h1>Home</h1>

      <Suspense fallback={<div>Loading recommended products...</div>}>
        <RecommendedProducts />
      </Suspense>

      <Suspense fallback={<div>Loading news...</div>}>
        <LatestNews />
      </Suspense>
    </div>
  );
}

Step 5: Loading and Error Handling

loading.tsx

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

error.tsx

// app/blog/error.tsx
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="text-center py-8">
      <h2 className="text-2xl font-bold text-red-600 mb-4">
        An error occurred
      </h2>
      <p className="text-gray-600 mb-4">{error.message}</p>
      <button
        onClick={reset}
        className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
      >
        Retry
      </button>
    </div>
  );
}

not-found.tsx

// 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-2xl font-bold mb-4">Article not found</h2>
      <p className="text-gray-600 mb-4">
        The article you're looking for doesn't exist or has been deleted.
      </p>
      <Link
        href="/blog"
        className="text-blue-500 hover:underline"
      >
        Back to blog list
      </Link>
    </div>
  );
}
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';

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

  if (!post) {
    notFound(); // Display not-found.tsx
  }

  return <article>{/* ... */}</article>;
}

Step 6: Server Actions

Basic Server Action

// app/contact/page.tsx
async function submitContact(formData: FormData) {
  'use server';

  const name = formData.get('name') as string;
  const email = formData.get('email') as string;
  const message = formData.get('message') as string;

  // Save to database
  await db.contact.create({
    data: { name, email, message },
  });

  // Send email, etc.
}

export default function ContactPage() {
  return (
    <form action={submitContact} className="space-y-4">
      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          name="name"
          required
          className="w-full border p-2 rounded"
        />
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          required
          className="w-full border p-2 rounded"
        />
      </div>
      <div>
        <label htmlFor="message">Message</label>
        <textarea
          id="message"
          name="message"
          required
          className="w-full border p-2 rounded"
        />
      </div>
      <button
        type="submit"
        className="px-4 py-2 bg-blue-500 text-white rounded"
      >
        Submit
      </button>
    </form>
  );
}

useFormState and useFormStatus

// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function createPost(prevState: any, formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  if (!title || title.length < 3) {
    return { error: 'Title must be at least 3 characters' };
  }

  await db.post.create({
    data: { title, content },
  });

  revalidatePath('/posts');
  return { success: true };
}
// app/posts/new/page.tsx
'use client';

import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { createPost } from '@/app/actions';

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending}
      className="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
    >
      {pending ? 'Submitting...' : 'Post'}
    </button>
  );
}

export default function NewPostPage() {
  const [state, formAction] = useActionState(createPost, null);

  return (
    <form action={formAction} className="space-y-4">
      {state?.error && (
        <p className="text-red-500">{state.error}</p>
      )}

      <div>
        <label htmlFor="title">Title</label>
        <input
          id="title"
          name="title"
          className="w-full border p-2 rounded"
        />
      </div>

      <div>
        <label htmlFor="content">Content</label>
        <textarea
          id="content"
          name="content"
          className="w-full border p-2 rounded"
        />
      </div>

      <SubmitButton />
    </form>
  );
}

Best Practices

1. Component Usage
   - Data fetching → Server Component
   - Interactive features → Client Component
   - Use 'use client' only where needed

2. Data Fetching
   - Fetch in parallel when possible
   - Choose appropriate cache strategy
   - Progressive rendering with Suspense

3. Performance
   - Dynamic imports for code splitting
   - Use next/image for images
   - Use next/font for fonts

4. Error Handling
   - Handle appropriately with error.tsx
   - 404 pages with not-found.tsx
   - Loading states with loading.tsx

Summary

Next.js App Router is a new architecture combining Server Components and Client Components. Using them appropriately improves both performance and developer experience.

← Back to list