Module 2: App Router Essentials — Layouts, Nested Routes & Navigation in Next.js 16

Module 2: App Router Essentials — Layouts, Nested Routes & Navigation in Next.js 16

AI Reading

Quick summary of this article

Master the App Router file conventions in Next.js 16: pages, nested layouts, route groups, the Link component and active link highlighting with TypeScript.

  • Nested layouts let you share a header, sidebar or footer across routes without re-rendering it on every navigation.
  • loading.tsx and error.tsx give you instant loading skeletons and error boundaries for free at any route level.
  • The Link component prefetches routes in the viewport, and usePathname() highlights the active nav item.
  • Route groups (marketing) and (shop) let you organise files and apply different layouts without changing the URL.

Complete Next.js 16 Series — Module 2 of 12

Module 1 gave you one page. Real applications have dozens of routes sharing chrome — a header, a sidebar, a footer — and they need to feel instant when you click. The App Router solves both problems with file conventions rather than configuration.

The Seven File Conventions

Inside any folder under src/app/, these filenames have special meaning:

  • page.tsx — the route’s UI. A folder is only routable if it has one.
  • layout.tsx — shared UI that wraps the page and its children. Preserved across navigation.
  • loading.tsx — an instant loading skeleton shown while the page streams in.
  • error.tsx — an error boundary for the route and its children.
  • not-found.tsx — UI for notFound() calls and unmatched URLs.
  • template.tsx — like a layout, but it remounts on every navigation.
  • route.ts — an HTTP handler (a REST endpoint), covered in Module 8.

The rule that trips people up: a folder with no page.tsx is not a route. src/app/dashboard/settings/ with only a component file returns a 404.

Building a Nested Layout

Let’s build a small application shell. Create this structure:

src/app/
├── layout.tsx
├── page.tsx
├── about/
│   └── page.tsx
└── dashboard/
    ├── layout.tsx
    ├── page.tsx
    ├── settings/
    │   └── page.tsx
    └── reports/
        └── page.tsx

The URLs produced are /, /about, /dashboard, /dashboard/settings and /dashboard/reports.

Now the dashboard layout at src/app/dashboard/layout.tsx:

import Link from "next/link";

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="mx-auto flex max-w-6xl gap-8 px-6 py-10">
      <aside className="w-56 shrink-0">
        <nav className="flex flex-col gap-1">
          <Link href="/dashboard" className="rounded px-3 py-2 hover:bg-neutral-100">
            Overview
          </Link>
          <Link href="/dashboard/reports" className="rounded px-3 py-2 hover:bg-neutral-100">
            Reports
          </Link>
          <Link href="/dashboard/settings" className="rounded px-3 py-2 hover:bg-neutral-100">
            Settings
          </Link>
        </nav>
      </aside>

      <section className="min-w-0 flex-1">{children}</section>
    </div>
  );
}

Then each page stays tiny — src/app/dashboard/reports/page.tsx:

export default function ReportsPage() {
  return (
    <div>
      <h1 className="text-2xl font-bold">Reports</h1>
      <p className="mt-2 text-neutral-600">Monthly performance summary.</p>
    </div>
  );
}

Why layouts matter more than you think

When you navigate from /dashboard/reports to /dashboard/settings, Next.js renders only the changed segment. The dashboard sidebar is not re-rendered, its scroll position is preserved, and any client-side state inside it survives — because React reconciles the shared layout instead of replacing it.

That also means a layout cannot access params of the page below it, and data fetched in a layout is not automatically shared with the page. Fetching is deduplicated per request, which we exploit in Module 6.

Client-Side Navigation and Active Links

<Link> is not a plain anchor. It prefetches routes as they enter the viewport, so the navigation feels instant.

import Link from "next/link";

To highlight the current page you need the pathname, which is a client hook. Create src/components/NavLink.tsx:

"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";

type Props = {
  href: string;
  children: React.ReactNode;
};

export default function NavLink({ href, children }: Props) {
  const pathname = usePathname();
  const isActive = pathname === href;

  return (
    <Link
      href={href}
      aria-current={isActive ? "page" : undefined}
      className={`rounded px-3 py-2 text-sm transition ${
        isActive
          ? "bg-neutral-900 text-white"
          : "text-neutral-700 hover:bg-neutral-100"
      }`}
    >
      {children}
    </Link>
  );
}

"use client" on line 1 marks this file as a Client Component. That is the entire opt-in mechanism, and Module 5 is dedicated to it.

Loading States That Feel Instant

Add src/app/dashboard/loading.tsx:

export default function Loading() {
  return (
    <div className="animate-pulse space-y-4">
      <div className="h-8 w-48 rounded bg-neutral-200" />
      <div className="h-4 w-full rounded bg-neutral-200" />
      <div className="h-4 w-2/3 rounded bg-neutral-200" />
    </div>
  );
}

Next.js wraps the segment in a Suspense boundary automatically. The moment you click a slow dashboard page, the shell and skeleton appear immediately while the data resolves. No spinner libraries, no isLoading flags.

Error Boundaries per Segment

Add src/app/dashboard/error.tsx. It must be a Client Component:

"use client";

export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div role="alert" className="rounded border border-red-300 bg-red-50 p-6">
      <h2 className="font-semibold text-red-800">Something went wrong</h2>
      <p className="mt-1 text-sm text-red-700">{error.message}</p>
      <button
        onClick={reset}
        className="mt-4 rounded bg-red-600 px-4 py-2 text-sm text-white hover:bg-red-700"
      >
        Try again
      </button>
    </div>
  );
}

Errors thrown in /dashboard/* render here instead of crashing the whole app. The root layout keeps working, so users can still navigate away — a far better experience than a white screen.

Route Groups

Sometimes you want separate layouts without changing URLs. Wrap a folder name in parentheses:

src/app/
├── (marketing)/
│   ├── layout.tsx        → public header + footer
│   ├── page.tsx          → /
│   └── pricing/
│       └── page.tsx      → /pricing
└── (app)/
    ├── layout.tsx        → sidebar shell, requires auth
    └── dashboard/
        └── page.tsx      → /dashboard

(marketing) and (app) do not appear in the URL. You get two independent layouts, and in Module 10 we use (app) to place every authenticated route behind one guard.

Two route groups cannot resolve to the same URL — (a)/about/page.tsx and (b)/about/page.tsx is a build error.

template.tsx vs layout.tsx

Use template.tsx when the wrapper must reset on every navigation — for example a page-entry animation or a form whose state must clear between routes. It behaves like a layout but produces a new component instance each time, which is slower but sometimes exactly what you need.

Module 2 Recap

  • Only folders containing page.tsx are routable.
  • Nested layouts persist across navigation and protect shared state.
  • loading.tsx and error.tsx give you streaming skeletons and error boundaries per segment.
  • Route groups (name) organise files and layouts without touching the URL.
  • <Link> prefetches; usePathname() drives active-link styling and requires "use client".

Next up — Module 3: styling with Tailwind CSS 4, building a responsive layout, and dark mode using the class strategy.

Leave a Reply

Your email address will not be published. Required fields are marked *