Next.js 16 defaults to the App Router, and its defining change is that components are server components unless you say otherwise. That single default explains most of what feels unfamiliar coming from the Pages Router: no getServerSideProps, async components, and a 'use client' directive you have to add deliberately.
This walks through a new project end to end: structure, routing, data fetching, layouts, metadata and deployment. Versions used throughout are Next.js 16.0.10 and React 19.2.
Prerequisites
Before diving in, make sure you have:
- Node.js 20+ installed
- Basic familiarity with React and TypeScript
- A code editor (VS Code recommended)
Project Setup
Scaffold a new project with the official create-next-app CLI:
npx create-next-app@latest my-app --typescript --tailwind --app
cd my-app
npm run dev
Your project will be running at http://localhost:3000.
Understanding the App Router
The App Router is the heart of Next.js 16. Unlike the old pages/ directory, everything in app/ is a React Server Component by default.
File Conventions
src/app/
├── layout.tsx # Root layout (wraps every page)
├── page.tsx # Homepage → /
├── about/
│ └── page.tsx # About page → /about
└── blog/
├── page.tsx # Blog listing → /blog
└── [slug]/
└── page.tsx # Dynamic route → /blog/:slug
Server vs Client Components
This is the key mental model shift:
[!NOTE] Server Components run on the server at build time or request time. They can fetch data directly, read files, and access databases, but they cannot use
useState,useEffect, or event handlers.
[!TIP] Client Components are marked with
"use client"at the top of the file. They run in the browser and support interactivity. Keep them small and push them to the leaves of your component tree.
// Server Component (default) - no directive needed
export default async function BlogList() {
const posts = await db.posts.findMany(); // Direct DB access ✅
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
// Client Component - needs the directive
"use client";
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0); // useState ✅
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
Data Fetching
Server Components make data fetching dramatically simpler:
// No useEffect, no loading state, no API routes needed
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await fetch(`https://api.example.com/products/${params.id}`, {
next: { revalidate: 3600 }, // ISR: revalidate every hour
}).then(r => r.json());
return (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
</div>
);
}
Layouts
Layouts persist between navigations; they don't unmount. This is perfect for headers, footers, and sidebars:
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Header />
<main>{children}</main>
<Footer />
</body>
</html>
);
}
Static Generation with generateStaticParams
For dynamic routes, export generateStaticParams to pre-render pages at build time:
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
Caching is the part that will surprise you
The App Router caches aggressively by default, and nearly every "why is my data stale" question comes from not knowing which of the layers is holding it.
| Layer | Caches | Cleared by |
|---|---|---|
| Request memoisation | identical fetch calls in one render pass | end of the request |
| Data Cache | fetch results across requests and deploys | revalidate, revalidateTag, revalidatePath |
| Full Route Cache | rendered HTML for static routes | a new deploy, or revalidation |
| Router Cache | RSC payloads client-side during navigation | a full reload, router.refresh() |
Control the fetch layer per call:
await fetch(url, { cache: 'no-store' }); // always fresh
await fetch(url, { next: { revalidate: 60 } }); // at most 60s stale
await fetch(url, { next: { tags: ['posts'] } }); // invalidate by tag
Then invalidate a tag from a Server Action or route handler when the data actually changes, which is better than guessing a revalidate interval:
import { revalidateTag } from 'next/cache';
export async function createPost(formData: FormData) {
'use server';
await db.posts.create({ title: formData.get('title') });
revalidateTag('posts');
}
Route-level opt-outs exist too, and they are blunt instruments worth knowing about:
export const dynamic = 'force-static'; // prerender, never server-render
export const dynamic = 'force-dynamic'; // server-render every request
export const revalidate = 3600; // ISR, regenerate hourly
[!WARNING] Reading
cookies(),headers()orsearchParamsopts a route into dynamic rendering for the whole route, whether you meant it or not. A route you expected to be static becoming server-rendered on every request is almost always one of these three, and it is the most common cause of an unexpected hosting bill.
Metadata API
Replace <Head> with the built-in Metadata API:
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "My Page",
description: "Page description for SEO",
openGraph: { title: "My Page", type: "article" },
};
Server Actions replace the API route for mutations
In the Pages Router, a form meant an API route and a fetch from the client. Server Actions collapse that into a function that runs on the server and is called directly from the component:
// app/posts/new/page.tsx
export default function NewPost() {
async function create(formData: FormData) {
'use server';
await db.posts.create({ title: formData.get('title') as string });
revalidatePath('/posts');
redirect('/posts');
}
return (
<form action={create}>
<input name="title" required />
<button type="submit">Publish</button>
</form>
);
}
No API route, no client-side fetch, and the form works before JavaScript loads because it is a real form submission.
Two things to be clear about. A Server Action is a public HTTP endpoint, generated for you, so it needs authentication and validation exactly like a route handler would. "It is only called from my form" is not access control. And every argument crosses the network, so it must be serialisable, which is the same constraint as props on a Client Component.
The conventions that differ from the Pages Router
If you are migrating, these are the renames that trip people up:
| Pages Router | App Router |
|---|---|
getServerSideProps | async component, or fetch with cache: 'no-store' |
getStaticProps | async component, cached by default |
getStaticPaths | generateStaticParams |
_app.tsx, _document.tsx | app/layout.tsx |
next/router | next/navigation |
pages/api/* | app/api/*/route.ts, or a Server Action |
<Head> | export const metadata, or generateMetadata |
The one that causes real bugs is next/router to next/navigation. Importing useRouter from the old path in an App Router component fails at runtime rather than at build, and the message does not point at the import.
Deployment
Push to GitHub and connect to Vercel for zero-config deployment:
git push origin main
# Vercel auto-detects Next.js and configures everything
Where new projects usually go wrong
Four mistakes account for most of the early friction, and all four are cheap to avoid.
Marking the page 'use client' instead of the component that needs it. The directive is a boundary, not a file annotation: everything imported from that file down becomes client code. Put it on the interactive leaf, not the page that contains it.
Fetching in a useEffect inside a Client Component. If the data is needed to render, fetch it in the Server Component and pass it down. The effect version adds a round trip after mount and a loading state you did not need.
Not realising a route went dynamic. Reading cookies(), headers(), or searchParams opts the whole route into server rendering. A page you assumed was static being rendered on every request is the most common cause of an unexpected hosting bill.
Importing useRouter from next/router. That is the Pages Router path. In the App Router it comes from next/navigation, and the wrong import fails at runtime rather than at build, with a message that does not point at the import.
Summary
Next.js 16 with the App Router gives you:
- Server Components for zero-bundle-size data fetching
- Nested layouts that persist between routes
- Static generation with
generateStaticParams - Built-in metadata for SEO
- TypeScript-first developer experience
Start with Server Components everywhere, add "use client" only when you need interactivity. That's the golden rule.
Tools in this post
Related Tool
JSON Parser & Formatter
Validate, format, and minify JSON data with error highlighting.
Try it freeRelated Tool
XML Formatter & Beautifier
Beautify and minify XML code with syntax highlighting.
Try it freeWritten by
Jamith NimanthaSoftware developer. Builds the DebuggerMe tools and writes about the things he runs into shipping them.
Related Articles
All articles →React Server Components in Depth: What They Are and When to Use Them
React Server Components fundamentally change how we think about rendering. This guide breaks down how they work, how they differ from Client Components, and the patterns that will make your Next.js apps faster.
Why TypeScript Generics Are More Powerful Than You Think
A deep dive into TypeScript's generic type system, from basic usage to advanced patterns like conditional types, infer, and mapped types that will make your code safer and more expressive.
Stop Writing API Wrappers. Use TanStack Query Instead
Most frontend codebases have a homegrown API layer full of useEffect hacks, loading booleans, and stale data bugs. TanStack Query solves all of these in 20 lines. Here's how to migrate.