NoWaterProgramming

Next.js 16 App Router: Getting Started, and What the Model Costs

The App Router explained through a site that actually runs on it: server components, async request APIs, generateStaticParams, and the three things that break when you deploy somewhere other than Vercel.

13 min read
Share:

Checked against Next.js 16.1 on 2026-08-20.

The App Router's central idea is one line: every component runs on the server unless you opt it out. Everything else, the file conventions, the caching model, the data fetching, follows from that inversion. Get it and the rest is naming. Miss it and you end up with "use client" at the top of every page and a framework that is doing nothing for you.

This guide covers the model, the file conventions, and the parts the official docs are quiet about: what the App Router costs you, and what changes when the host is not Vercel. This blog is a Next.js 16 App Router site deployed to Cloudflare Workers, so the last part is reported rather than guessed.

Related: route handlers and the server actions decision, the React hooks guide for the client half, and App Router navigation measured for what <Link> and useRouter actually do.

What Changed in Next.js 16

If you are coming from 14 or 15, these are the things that will actually stop your build:

  • Synchronous request APIs are gone. params, searchParams, cookies(), headers() and draftMode() must all be awaited. Version 15 deprecated the synchronous form; 16 removed it.
  • Turbopack is the default for next dev and next build. A custom webpack config now fails the build unless you pass --webpack.
  • cacheComponents is a top-level config option, replacing experimental.dynamicIO and experimental.useCache. Turning it on surfaces build errors for uncached data that is not inside a Suspense boundary, which is a migration in itself.
  • size and First Load JS are gone from the build output. Vercel removed them because they were inaccurate for server-driven architectures, so if you tracked those numbers in CI, they no longer exist.

Creating a Project

npx create-next-app@latest my-app
bash
PromptRecommended
TypeScript?Yes
ESLint?Yes
Tailwind CSS?Yes
src/ directory?Taste
App Router?Yes
Import alias?@/*
cd my-app
npm run dev
bash

The File Conventions

Every folder under app/ can be a route; specific filenames decide what renders.

app/
  layout.tsx        # wraps every page, persists across navigation
  page.tsx          # the route itself
  loading.tsx       # Suspense fallback while the segment loads
  error.tsx         # error boundary for the subtree
  not-found.tsx     # rendered by notFound()
  blog/
    [slug]/
      page.tsx      # /blog/anything
  api/
    hello/
      route.ts      # GET /api/hello

The two worth internalising early are layout.tsx and loading.tsx. A layout persists across navigations within its segment, so state in it survives a route change. A loading.tsx is what stops a navigation feeling broken while data is fetched, and adding one is usually a bigger perceived-performance win than anything you will do to the bundle. It also changes how <Link> prefetches a route: without a loading.tsx, a static route is prefetched whole, which we measured at roughly half a page load per link in the viewport.

Server Components and Client Components

Server component, the default:

  • Runs on the server only, and its JavaScript is never sent to the browser.
  • Can read the database, the filesystem and secrets directly.
  • Cannot use useState, useEffect, refs or browser APIs.

Client component, opted in with a directive at the top of the file:

"use client";
 
import { useState } from "react";
 
export default function Counter() {
  const [count, setCount] = useState(0);
 
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
}
tsx
Server componentClient component
Fetching dataEvent handlers
Reading secrets and backend resourcesuseState, useEffect, refs
Keeping JavaScript off the clientBrowser APIs (localStorage, window)
Rendering static contentAnimation and interactive widgets

The rule that matters: push "use client" down to the smallest leaf that needs it. A client component's children can still be server-rendered if you pass them as children rather than importing them inside it, which is the escape hatch people miss when a provider forces a whole subtree client-side.

Data Fetching

There is no getServerSideProps. A server component is async, and you await:

// app/blog/page.tsx
export default async function BlogPage() {
  const res = await fetch("https://api.example.com/posts", {
    next: { revalidate: 3600 },
  });
  const posts: Post[] = await res.json();
 
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          <Link href={`/blog/${post.slug}`}>{post.title}</Link>
        </li>
      ))}
    </ul>
  );
}
tsx

Controls, from most cached to least:

  • { cache: "force-cache" } or no options: cached.
  • { next: { revalidate: 60 } }: regenerated at most every 60 seconds.
  • { cache: "no-store" }: fetched per request.
  • revalidatePath() and revalidateTag() inside a server action: invalidate on demand.

Under cacheComponents in 16, the newer path is the 'use cache' directive on the function that does the expensive work, which is finer-grained than a per-route setting. The route handlers post covers that in detail.

Server Actions

A function that runs on the server and can be called from a form or a client component, with no endpoint in between:

// app/contact/page.tsx
export default function ContactPage() {
  async function submitForm(formData: FormData) {
    "use server";
 
    await saveContact({
      name: formData.get("name") as string,
      email: formData.get("email") as string,
    });
  }
 
  return (
    <form action={submitForm}>
      <input name="name" required />
      <input name="email" type="email" required />
      <button type="submit">Send</button>
    </form>
  );
}
tsx

Because it is the action of a real <form>, it works before the page's JavaScript has loaded.

Dynamic Routes and generateStaticParams

generateStaticParams tells Next.js which values to prerender. Here is the real version from this site, which is worth reading for the two lines around it:

// app/blog/[slug]/page.tsx
interface PageProps {
  params: Promise<{ slug: string }>;
}
 
// Every published post is prerendered, so an unmatched slug is genuinely missing
// rather than something to render on demand.
export const dynamicParams = false;
 
export function generateStaticParams() {
  return getPostSlugs().map((slug) => ({ slug }));
}
 
export default async function BlogPost({ params }: PageProps) {
  const { slug } = await params;
  const post = getPostBySlug(slug);
 
  if (!post) notFound();
  // ...
}
tsx

params is a Promise. That is the change most old tutorials get wrong, including the previous version of this post, and it is not optional in 16.

dynamicParams = false is the other half. By default an unknown slug is server-rendered on first request, which for a blog means an unmatched URL is an error page served with a 200 unless you handle it. Setting it to false makes anything outside generateStaticParams a real 404. Whether you want that depends on whether your route set is closed; ours is.

Metadata

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const { slug } = await params;
  const post = getPostBySlug(slug);
 
  if (!post) return {};
 
  return {
    title: post.frontmatter.title,
    description: post.frontmatter.description,
    alternates: { canonical: absoluteUrl(`/blog/${slug}`) },
    openGraph: {
      title: post.frontmatter.title,
      description: post.frontmatter.description,
      type: "article",
      publishedTime: post.frontmatter.date,
    },
  };
}
tsx

It runs on the server, so the metadata can depend on the same data the page renders, and it lands in the HTML rather than being set by client JavaScript. That matters more than it used to: crawlers that do not execute JavaScript see only what is in the response.

Middleware

// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
 
export function middleware(request: NextRequest) {
  const token = request.cookies.get("auth-token");
 
  if (!token && request.nextUrl.pathname.startsWith("/dashboard")) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
 
  return NextResponse.next();
}
 
export const config = {
  matcher: ["/dashboard/:path*"],
};
tsx

Middleware runs before every matching request. Keep it small: it is on the hot path for every navigation it matches, and it is the wrong place for anything that needs a database round trip.

Deployment, Including Off Vercel

Vercel is zero-config and the docs assume it. The other three options each cost something specific.

Self-hosted Node. next build then next start. Everything works, you own the process, the cache is on local disk, and horizontal scaling means the cache is per instance unless you configure shared storage.

Docker. The same, containerised.

Static export. output: "export" gives you files on any CDN, at the price of route handlers, middleware, server actions, image optimisation and on-demand revalidation. It is a different framework wearing the same name.

Cloudflare Workers via OpenNext. This is what this site runs on, and three things needed real work.

First, there is no filesystem at runtime. Reading the content/ directory per request is a normal thing to do in a Node deployment and simply does not exist on a Worker. The fix is to generate the index at build time and import it:

// lib/posts.ts
import generatedPosts from '@/lib/generated/posts-data.json';
 
/**
 * Build-time generated index of *published* posts, newest first. The app reads this
 * rather than the filesystem because Cloudflare Workers have no fs at runtime.
 */
const posts: PostMetadata[] = (generatedPosts as GeneratedPost[]).map(toPostMetadata);
ts

Second, NEXT_PUBLIC_* is not a runtime variable. Next.js inlines those at build time by substituting the literal process.env.NEXT_PUBLIC_SITE_URL expression in your source. It cannot be read through a computed key, re-exported from a helper, or supplied by a Worker binding. It has to be written out exactly, once:

const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
ts

Third, server-side secrets arrive on the request context, not on process.env, so anything reading them needs a lookup that falls back for local dev. That, plus pointing the incremental cache at real storage, is covered in the route handlers post.

None of this is a criticism of the adapter, which does a lot of work. It is the general shape of the thing: the App Router is portable, its infrastructure assumptions are not.

What the App Router Costs

  • Two execution models in one codebase. Every file is server or client, the boundary is a string at the top of a file, and the error you get for crossing it wrongly is rarely the error you would want. This is the single largest source of confusion for people arriving from the Pages Router.
  • The caching model is the hard part, and it keeps moving. Route-segment config, fetch options, revalidateTag, and now 'use cache' and cacheComponents. Defaults changed between 14, 15 and 16. Any answer you find online needs its version checked before you believe it.
  • "use client" is contagious upward through imports. A component imported into a client component is client-side too, so one careless import can pull a large subtree off the server.
  • Upgrades are real work. The async request APIs alone touch every dynamic route, layout and metadata function in a project.
  • The ecosystem lags. Libraries that touch context, theming or CSS-in-JS assumed a client-rendered tree for years. Most have caught up; check before you commit to one.
  • Turbopack by default in 16 means a custom webpack config is now a decision, not a given. If you rely on a webpack loader, you are opting out of the default build path.

The trade is still usually worth it for a content-heavy or data-heavy app. It is not obviously worth it for a dashboard that is interactive on every pixel, where most components end up client-side anyway.

Common Pitfalls

"use client" at the top of the page. It disables the model wholesale. Extract the interactive leaf instead.

Fetching in a client component out of habit. If the data does not depend on user interaction, fetch it on the server and pass it down.

Treating params as an object. It is a promise in 15 and 16. This one survives a test suite easily, because a hand-written test supplies params itself and usually supplies the old shape: we measured it and a direct-call test passes against the broken handler.

No loading.tsx. Navigation feels broken during data fetches, and this is a two-line file.

Duplicating chrome across pages. That is what layouts are for.

FAQ

What is the difference between the App Router and the Pages Router?

The Pages Router uses pages/ with getServerSideProps and getStaticProps, and every component ships to the browser. The App Router uses app/, makes React Server Components the default so most components never ship JavaScript, and replaces the data-fetching functions with async components. The App Router is where new development goes; the Pages Router still works.

Is the App Router ready for production?

Yes. Server Components, Server Actions and the App Router itself are stable and have been for several major versions. The part that has kept changing is caching semantics, which shifted meaningfully in 15 and again in 16, so pin your version and read the upgrade guide rather than assuming a blog post from last year still applies.

Do I need to know React to learn Next.js?

You need components, props, state and hooks. You do not need to be an expert, but you do need to be comfortable with the idea that a Next.js server component is a React component that never reaches the browser, which is a concept plain React does not have.

Why do I get an error about params in Next.js 15 or 16?

Because params and searchParams became promises in 15, and 16 removed synchronous access entirely. Type them as Promise<{ ... }> and await them. The same applies to cookies(), headers() and draftMode().

Can I deploy Next.js somewhere other than Vercel?

Yes, with caveats that depend on the target. next start on a Node host is the closest to Vercel's behaviour. Cloudflare Workers via the OpenNext adapter works, but there is no runtime filesystem, NEXT_PUBLIC_* values are inlined at build time, and the incremental cache has to be pointed at real storage. Static export removes route handlers, middleware, server actions and image optimisation altogether.

How do I handle environment variables?

Server components, server actions and route handlers read process.env directly. Anything the browser needs must be prefixed NEXT_PUBLIC_, which inlines it into the client bundle at build time, so never put a secret behind that prefix. On hosts that supply configuration as request-scoped bindings rather than process environment, read those through the platform adapter instead.

Sources

Checked 2026-08-20.

Related Posts

19 min read
We measured client-side navigation on a deployed Next.js 16.1 site: a soft navigation transfers about half the bytes of a full page load, and a <Link> prefetch transfers the same payload as the navigation it prepares - the whole route, once per link in the viewport, in production. Then we read the useRouter return type out of the installed package: six methods, and no router.events, router.query, router.pathname, router.isReady, router.beforePopState or shallow routing.
By NoWaterProgramming Team
15 min read
We seeded thirteen failure modes into Next.js 16.1 route handlers and recorded the status, headers and body the client got back, under next dev and under a production build. An uncaught throw is an empty 500 with no Content-Type. Throwing a Response does not set its status. A stream that fails after its first byte is a truncated 200. Dev and production returned the same thing every time.
By NoWaterProgramming Team
18 min read
We put ten defects into real route handlers and ran four testing methods at them: a direct call, next-test-api-route-handler, next start, and the Cloudflare Workers build we deploy. Two methods pass a handler that is broken, and one fails a handler that is fine.
Next.jstesting
By NoWaterProgramming Team