Next.js Full Course · Lesson 1 of 3

Next.js App Router: Complete Guide

App Router vs Pages Router

Next.js has two routing systems. The App Router (introduced in Next.js 13, stable in 14) is the future:

| | App Router (app/) | Pages Router (pages/) | |--|---------------------|------------------------| | Default component type | Server Component | Client Component | | Data fetching | async/await in components | getServerSideProps / getStaticProps | | Layouts | Nested layout files | _app.tsx + manual nesting | | Loading states | loading.tsx file | Manual skeleton components | | Error handling | error.tsx file | Error boundaries | | Caching | Granular, per-fetch | Page-level |

Use the App Router for all new projects.


Project Structure

app/
├── layout.tsx           ← root layout (wraps everything)
├── page.tsx             ← homepage (/)
├── globals.css
├── dashboard/
│   ├── layout.tsx       ← dashboard layout (wraps /dashboard/*)
│   ├── page.tsx         ← /dashboard
│   └── settings/
│       └── page.tsx     ← /dashboard/settings
├── blog/
│   ├── page.tsx         ← /blog (list)
│   └── [slug]/
│       └── page.tsx     ← /blog/my-post (dynamic)
└── api/
    └── orders/
        └── route.ts     ← /api/orders (API endpoint)

File conventions:

  • page.tsx — the UI for a route (makes it accessible)
  • layout.tsx — wraps children, persists across navigations
  • loading.tsx — shown while the page is loading (Suspense boundary)
  • error.tsx — shown when a component throws
  • not-found.tsx — shown when notFound() is called
  • route.ts — API endpoint (no UI)

Server Components

By default, every component in the app/ directory is a Server Component — it runs only on the server, never in the browser.

TSX
// app/dashboard/page.tsx — Server Component (default)
// This runs on the server — no "use client", no useEffect needed
export default async function DashboardPage() {
    // Direct database access — no API call needed
    const stats = await db.query(`
        SELECT COUNT(*) as orders, SUM(total) as revenue
        FROM Orders WHERE created_at > NOW() - INTERVAL '30 days'
    `);

    const recentOrders = await db.orders.findMany({
        orderBy: { createdAt: "desc" },
        take: 10,
    });

    return (
        <main>
            <h1>Dashboard</h1>
            <StatsCards stats={stats} />
            <OrderTable orders={recentOrders} />
        </main>
    );
}

Server Components:

  • Can access databases, file system, env variables directly
  • Never expose secrets to the browser
  • Reduce JavaScript bundle size (component code stays on server)
  • Cannot use useState, useEffect, event handlers, or browser APIs

Client Components

Add "use client" at the top for components that need interactivity:

TSX
// components/counter.tsx
"use client";

import { useState } from "react";

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

    return (
        <div>
            <p>Count: {count}</p>
            <button onClick={() => setCount(c => c + 1)}>Increment</button>
        </div>
    );
}
TSX
// app/page.tsx — Server Component
import { Counter } from "@/components/counter";

export default function HomePage() {
    return (
        <div>
            <h1>Welcome</h1>
            <Counter />   {/* Client Component inside Server Component ✅ */}
        </div>
    );
}

Key rule: push "use client" as far down the component tree as possible. Keep the data-fetching at the top as Server Components; only the interactive leaf nodes need to be Client Components.


Data Fetching

Server Component data fetching

TSX
// Async server component — fetch data at render time
export default async function ProductsPage() {
    // fetch is automatically deduplicated and cached
    const products = await fetch("https://api.example.com/products", {
        next: { revalidate: 3600 }   // cache for 1 hour, then revalidate
    }).then(r => r.json());

    return <ProductList products={products} />;
}

Parallel data fetching

TSX
export default async function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
    const { id } = await params;

    // Fetch in parallel — don't await sequentially
    const [order, customer, inventory] = await Promise.all([
        getOrder(id),
        getCustomer(id),
        getInventory(id),
    ]);

    return <OrderDetail order={order} customer={customer} inventory={inventory} />;
}

Cache strategies

TypeScript
// Static — cached indefinitely, rebuild to update
fetch(url, { cache: "force-cache" });

// Revalidate — cached, refreshed in background after N seconds
fetch(url, { next: { revalidate: 60 } });

// Dynamic — never cached, always fresh
fetch(url, { cache: "no-store" });

// Tag-based — invalidate specific caches on demand
fetch(url, { next: { tags: ["orders"] } });

// Revalidate by tag from a Server Action or API route
import { revalidateTag } from "next/cache";
revalidateTag("orders");

Layouts

Layouts persist across navigations in their segment — ideal for navigation, sidebars, auth wrappers:

TSX
// app/layout.tsx — root layout
import type { Metadata } from "next";

export const metadata: Metadata = {
    title:       { default: "Learnixo", template: "%s | Learnixo" },
    description: "Free developer learning platform",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
    return (
        <html lang="en">
            <body>
                <Header />
                <main>{children}</main>
                <Footer />
            </body>
        </html>
    );
}
TSX
// app/dashboard/layout.tsx — nested layout
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
    return (
        <div className="flex">
            <Sidebar />              {/* Persists when navigating between dashboard pages */}
            <div className="flex-1">{children}</div>
        </div>
    );
}

Dynamic Routes

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

export default async function BlogPostPage({ params }: Props) {
    const { slug } = await params;
    const post = await getPostBySlug(slug);
    if (!post) notFound();

    return <article><h1>{post.title}</h1><MDXContent source={post.content} /></article>;
}

// Generate static pages at build time (optional)
export async function generateStaticParams() {
    const posts = await getAllPosts();
    return posts.map(post => ({ slug: post.slug }));
}

// SEO metadata per page
export async function generateMetadata({ params }: Props): Promise<Metadata> {
    const { slug } = await params;
    const post = await getPostBySlug(slug);
    return {
        title:       post?.title ?? "Not Found",
        description: post?.excerpt,
        openGraph:   { images: [post?.coverImage ?? ""] },
    };
}

Loading and Error States

TSX
// app/dashboard/loading.tsx — shown instantly while page loads
export default function DashboardLoading() {
    return (
        <div className="space-y-4 p-8">
            <div className="h-8 w-48 bg-muted animate-pulse rounded" />
            <div className="grid grid-cols-3 gap-4">
                {[1, 2, 3].map(i => (
                    <div key={i} className="h-32 bg-muted animate-pulse rounded-xl" />
                ))}
            </div>
        </div>
    );
}
TSX
// app/dashboard/error.tsx — shown when the page throws
"use client";   // error components must be Client Components

export default function DashboardError({
    error,
    reset,
}: {
    error: Error;
    reset: () => void;
}) {
    return (
        <div className="text-center py-20">
            <h2 className="text-xl font-bold mb-4">Something went wrong</h2>
            <p className="text-muted-foreground mb-6">{error.message}</p>
            <button onClick={reset} className="btn btn-primary">Try again</button>
        </div>
    );
}

API Routes

TypeScript
// app/api/orders/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
    const { searchParams } = request.nextUrl;
    const status = searchParams.get("status") ?? "all";

    const orders = await db.orders.findMany({
        where: status !== "all" ? { status } : undefined,
        orderBy: { createdAt: "desc" },
        take: 20,
    });

    return NextResponse.json({ orders });
}

export async function POST(request: NextRequest) {
    const body = await request.json();

    // Validate
    const parsed = createOrderSchema.safeParse(body);
    if (!parsed.success)
        return NextResponse.json({ errors: parsed.error.flatten() }, { status: 400 });

    const order = await db.orders.create({ data: parsed.data });
    return NextResponse.json({ order }, { status: 201 });
}
TypeScript
// app/api/orders/[id]/route.ts — dynamic API route
export async function GET(
    request: NextRequest,
    { params }: { params: Promise<{ id: string }> }
) {
    const { id } = await params;
    const order = await db.orders.findUnique({ where: { id } });
    if (!order) return NextResponse.json({ error: "Not found" }, { status: 404 });
    return NextResponse.json({ order });
}

Server Actions

Server Actions let you run server code directly from a form or button click — no API route needed:

TSX
// app/orders/actions.ts
"use server";

import { revalidatePath } from "next/cache";

export async function createOrderAction(formData: FormData) {
    const customerId = formData.get("customerId") as string;
    const items      = JSON.parse(formData.get("items") as string);

    // Validate
    if (!customerId) throw new Error("Customer ID required");

    // Run on server — direct DB access
    const order = await db.orders.create({
        data: { customerId, items, status: "pending" }
    });

    revalidatePath("/orders");   // revalidate the orders list cache
    return { orderId: order.id };
}
TSX
// app/orders/new/page.tsx — use the server action in a form
import { createOrderAction } from "../actions";

export default function NewOrderPage() {
    return (
        <form action={createOrderAction}>
            <input name="customerId" placeholder="Customer ID" />
            <input name="items" type="hidden" value="[]" />
            <button type="submit">Create Order</button>
        </form>
    );
}

Middleware — Auth Guard

TypeScript
// middleware.ts (root of project)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
    const token = request.cookies.get("auth-token")?.value;

    const isProtected = request.nextUrl.pathname.startsWith("/dashboard");

    if (isProtected && !token) {
        const loginUrl = new URL("/login", request.url);
        loginUrl.searchParams.set("callbackUrl", request.nextUrl.pathname);
        return NextResponse.redirect(loginUrl);
    }

    return NextResponse.next();
}

export const config = {
    matcher: ["/dashboard/:path*", "/api/:path*"],
};

Key Takeaways

  • Server Components by default — only add "use client" when you need interactivity
  • Push "use client" down — keep data fetching in Server Components, interactivity in leaf nodes
  • Parallel data fetching with Promise.all — never await sequentially if not dependent
  • Cache strategies per fetch — force-cache, revalidate: N, no-store, tag-based
  • Layouts persist across navigations — use them for nav, sidebars, auth wrappers
  • loading.tsx and error.tsx handle loading and error states automatically
  • Server Actions replace simple API routes — run server code from forms, no round-trip needed
  • Middleware for auth guards, redirects, A/B testing — runs before any route handler