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 navigationsloading.tsx— shown while the page is loading (Suspense boundary)error.tsx— shown when a component throwsnot-found.tsx— shown whennotFound()is calledroute.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.
// 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:
// 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>
);
}// 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
// 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
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
// 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:
// 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>
);
}// 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
// 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
// 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>
);
}// 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
// 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 });
}// 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:
// 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 };
}// 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
// 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.tsxanderror.tsxhandle 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