Skip to content
All posts
Tutorial7 min read

Getting Started with Next.js 16: A Complete Guide

J
Jamith Nimantha
April 20, 2026
Next.js 16 logo with code snippets in the background
Photo by Unsplash
On this page

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:

bash
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

text
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.

tsx
// 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:

tsx
// 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:

tsx
// 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:

tsx
// 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.

LayerCachesCleared by
Request memoisationidentical fetch calls in one render passend of the request
Data Cachefetch results across requests and deploysrevalidate, revalidateTag, revalidatePath
Full Route Cacherendered HTML for static routesa new deploy, or revalidation
Router CacheRSC payloads client-side during navigationa full reload, router.refresh()

Control the fetch layer per call:

tsx
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:

tsx
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:

tsx
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() or searchParams opts 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:

tsx
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:

tsx
// 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 RouterApp Router
getServerSidePropsasync component, or fetch with cache: 'no-store'
getStaticPropsasync component, cached by default
getStaticPathsgenerateStaticParams
_app.tsx, _document.tsxapp/layout.tsx
next/routernext/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:

bash
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 free

Related Tool

XML Formatter & Beautifier

Beautify and minify XML code with syntax highlighting.

Try it free
J

Written by

Jamith Nimantha

Software developer. Builds the DebuggerMe tools and writes about the things he runs into shipping them.

Share this post

Back to all posts

Related Articles

All articles →