Next.js App Router: A Full-Stack Guide to Server Components and Routing

Next.js is the most widely used React full-stack framework today. The App Router introduced in v13 established "file-based routing, Server Components first" as the default mental model, and by v15/v16 it has become the foundation of the framework's routing and data layer — no longer just a replacement for the Pages Router. This guide starts from a minimal project structure, moves to runnable code, and ends with the trade-offs of migrating from the Pages Router. It is aimed at developers who already write React components and want to switch to the App Router fully.

Next.js is core content for both the frontend building and website building categories on 16IDC. To brush up on the React features underneath, read the React 19 guide first; for a deeper dive into first paint and SSR performance, see Next.js SSR performance optimization.

A Minimal Project: What the Conventions Look Like

The command below scaffolds an App Router project with TypeScript, Tailwind and ESLint; Turbopack is now the default bundler:

npx create-next-app@latest my-app --ts --tailwind --eslint --app
cd my-app && npm run dev

The core structure is just a few files:

my-app/
├── app/
│   ├── layout.tsx     # root layout: must contain <html> and <body>
│   ├── page.tsx       # home page, maps to /
│   └── globals.css
├── public/
└── next.config.ts

The App Router convention is straightforward: directory nesting expresses URL nesting, and each route directory uses special files to declare its behavior.

File Purpose
layout.tsx Shared layout; keeps state across child pages
page.tsx Page content; maps to an accessible route
loading.tsx Loading UI; pairs with Suspense for streaming
error.tsx Error boundary; catches errors per segment
not-found.tsx 404 page
route.ts API route handlers (GET/POST, etc.)

Dynamic routes use bracket directories: app/blog/[slug]/page.tsx matches /blog/hello-world and receives the param via params; [...slug] catches any number of segments. Layouts nest indefinitely, so hierarchies like "site nav / blog section / single post" map naturally onto a component tree, and each level can have its own loading and error boundaries.

Server and Client Components

Components under app/ are Server Components by default. Code written in them runs only on the server and never ships to the browser, so you can await database queries, read files, and call internal services directly. Only when you need interaction, state, or browser APIs do you add "use client" at the top of the file.

A typical blog index rendered in a Server Component that queries the database directly:

// app/blog/page.tsx -- a Server Component by default
export default async function BlogIndex() {
  const posts = await db.post.findMany({ take: 20 });
  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>
          <a href={`/blog/${p.slug}`}>{p.title}</a>
        </li>
      ))}
    </ul>
  );
}

The interactive "like" button goes into a Client Component instead:

// app/blog/like-button.tsx
"use client";
export function LikeButton({ postId }: { postId: string }) {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount((c) => c + 1)}>Like {count}</button>;
}

Two pitfalls worth remembering: Server Components cannot use useState, onClick or useEffect — if you see an error, check for a missing "use client". Conversely, don't put database connections or secrets in Client Components. Marking interactive leaves as client components and keeping everything else on the server is the core discipline of this model, and a big reason it shrinks the bundle.

Data Fetching, Caching and Streaming

In a Server Component you just await data; pair it with next/cache to control caching. Since Next.js 15, fetch is no longer cached by default and must be opted in explicitly:

export default async function Page() {
  const res = await fetch("https://api.example.com/posts", {
    next: { revalidate: 3600 }, // rebuild in the background every hour
  });
  const posts = await res.json();
  // ...
}

revalidate brings ISR down to the component level: pages rebuild on demand without a full redeploy. For finer control, use tag-based caching — tag the "post list", then call revalidateTag('posts') after publishing, and only the related pages go stale while the rest stay cached.

Streaming solves the "one slow endpoint blocks the whole page" problem. Split the page into multiple Suspense boundaries, each rendering a skeleton first and filling in content as data arrives. Time to first byte (TTFB) drops noticeably and perceived speed improves on slow networks. loading.tsx is essentially a default Suspense boundary for the whole route, so it usually works together with async data fetching.

Migrating from the Pages Router

Migration is scariest when you think you must rewrite everything. The App Router can coexist with the Pages Router, so you can migrate route by route. The rough mapping looks like this:

Pages Router App Router
pages/index.tsx app/page.tsx
pages/blog/[id].tsx app/blog/[id]/page.tsx
getServerSideProps direct fetching in a Server Component
getStaticProps + revalidate fetch + next.revalidate
_app.tsx root layout.tsx
pages/api/* app/api/*/route.ts

Three pitfalls people hit most: first, global layout injected by wrapping in _app.tsx must become a nested layout.tsx, otherwise child pages lose state on navigation; second, components that pull data in useEffect — see whether they can move into a Server Component and await synchronously, which usually deletes half the boilerplate; third, next/router APIs must be swapped for useRouter from next/navigation, and they are not identical.

A Real-World Example

Say you are rebuilding a site combining a corporate homepage, a content blog, and an admin dashboard. A typical App Router split:

  • app/(site)/**: the homepage and blog. Render everything with Server Components and statically cache public content with revalidate, which protects both SEO and first paint;
  • app/(dashboard)/**: the admin area. Requires login; handle auth once in the layout.tsx, and keep interactive form and chart components at the leaves;
  • Data layer: public content is fetched directly in Server Components or served from cache, while writes go through API routes defined in route.ts.

This "route groups + Server Components first" structure is far easier to maintain than the old "pure client rendering + global state" approach, and it combines well with the PWA implementation guide to push the mobile experience further.

Common Questions

  • useState throws as soon as I use it? Components are Server Components by default; add "use client" for interactivity.
  • loading.tsx does nothing? It only shows when the route has a genuinely pending async operation; fully static pages never trigger it.
  • Data never refreshes? Since Next.js 15, fetch is not cached by default; add an explicit revalidate or tag-based cache.
  • How do I choose between Next.js and Astro? For SaaS and dashboards with lots of dynamic interaction, Next.js is smoother; for pure content sites chasing maximum static output, look at the Astro blog-building guide.

For conventional page structures, also see the HTML page template and the responsive navbar template to scaffold the layout and styles first.

Reference: Next.js official docs https://nextjs.org/docs/app/getting-started/installation; data fetching and caching in the App Router https://nextjs.org/docs/app/building-your-application/data-fetching