Vercel Logo

Navigation

A user fills out half a form, clicks a nav link to check something, hits back—and their form data is gone. Full page reload. Or they're scrolled halfway down a list, navigate to a detail page, come back—scroll position reset to top. These aren't bugs in your code; they're the default behavior of plain <a> tags.

next/link enables soft navigation: client-side transitions that preserve state, maintain scroll position, and feel instant.

VIDEO PLACEHOLDER · 3-4 min · EVERGREEN · Low Priority

Soft Navigation: What Happens Under the Hood

Side-by-side comparison of hard navigation (full page reload) vs soft navigation (client-side transition). Shows network requests, what gets preserved (layouts, state), and why this matters for perceived performance.

Outcome

Meaningful links that soft-navigate and preserve state where needed.

Fast Track

  1. Replace plain anchors with next/link where appropriate.
  2. Verify soft navigation behavior.
  3. Handle focus management and accessibility.

Hands-On Exercise 2.6

Implement soft navigation with proper state preservation.

Requirements:

  1. Audit key navigations and swap to next/link.
  2. Ensure focus lands meaningfully after navigation.
  3. Confirm back/forward browser history behavior is intact.
  4. Demonstrate both client-side (useRouter) and server-side (redirect) navigation.

Key Concepts:

  • Soft navigation: Client-side route transitions without full page reload. The URL updates via JavaScript, only new page content is fetched, and shared layouts persist. State, scroll position, and interactivity are maintained.
  • Hard navigation: Traditional full page load (triggered by plain <a> tags or external navigation). Clears application state, resets scroll position, and causes a full browser refresh.

Implementation hints:

  • next/link provides soft navigation: No full page reloads, JavaScript updates URL, fetches only new page content, shared layouts persist.
  • Link behavior: Prefetching, client-side transitions, preserves state across navigation.
  • Back button: Browser navigation respects history; state is preserved.
  • useRouter (Client): For programmatic soft navigation (router.push(), router.back(), router.replace()), user interactions, conditional navigation.
  • redirect() (Server): For server-side navigation, authentication checks, form submissions after success.
  • Avoid client boundaries when not needed.
  • Keep link text accessible and descriptive.
Client-Side vs Server-Side Navigation

Use useRouter from 'next/navigation' for client-side programmatic navigation (user interactions). Use redirect() from 'next/navigation' for server-side navigation (auth checks, Server Actions).

Link vs Anchor Tag

Always prefer <Link> over <a> for internal navigation. Link provides soft navigation with fast client-side transitions, preserves component state, and enables prefetching. Plain anchors trigger hard navigation with full page reloads.

Prompt: Choose the Right Navigation Method
I need to implement navigation in Next.js and want to choose the right approach.
 
<context>
Next.js offers multiple navigation methods:
- `<Link>` component - declarative, for user clicks
- `useRouter().push/replace` - programmatic, client-side
- `redirect()` - server-side, in Server Components/Actions
- `useRouter().back/forward` - history navigation
</context>
 
<my-scenario>
**What triggers the navigation:** _____
Example: "User clicks a button" or "Form submission succeeds" or "Auth check fails"
 
**Where the code runs:**
 
- [ ] Server Component
- [ ] Client Component
- [ ] Server Action
- [ ] Route Handler
 
**User experience requirements:**
 
- Should URL change? _____
- Should user be able to go back? _____
- Should form/scroll state be preserved? _____
- Must work without JavaScript? _____
</my-scenario>
 
<my-current-code>
```tsx
// What I'm trying to do:
___PASTE_YOUR_CODE_OR_DESCRIBE___
```
</my-current-code>
 
**Questions:**
 
1. Which navigation method fits my scenario?
2. What are the tradeoffs of each option?
3. What happens if I use the wrong method?
 
Recommend the right navigation method with a code example for my specific scenario.
Optional: Generate with v0

Use v0 to scaffold a responsive nav bar and breadcrumb trail. Keep everything presentational; links must be real <Link> usages in our codebase, not hardcoded anchors from v0 output.

Prompt:

Generate a responsive top navigation with logo primary links and a breadcrumb component using Tailwind presentational only no data fetching output semantic HTML and accessible focus management.

Open in v0: Open in v0

Try It

  • Navigate between listing and detail; confirm no full page reload.

Commit & Deploy

git add -A
git commit -m "feat(core): implement soft navigation via next/link"
git push -u origin feat/core-next-link

Done-When

  • Navigate to /nav-demo, click "Go to Page A": URL changes to /nav-demo/page-a without page refresh (soft navigation)
  • Click browser back button: returns to /nav-demo with state preserved
  • Click "Hard nav to Page A": browser loading indicator appears (full page reload)
  • On /nav-demo, click "router.push()": navigates programmatically without page refresh
  • Click "router.replace()": navigates but back button skips the replaced entry
  • Open DevTools Network tab during soft navigation: only RSC payload fetched (not full HTML)

Solution

Solution

This solution demonstrates soft navigation with next/link, programmatic navigation with useRouter, and server-side navigation with redirect().

Step 1: Create the Navigation Demo Page

apps/web/src/app/nav-demo/page.tsx
// Lesson 2.6: Navigation Semantics
// Demonstrates soft navigation with next/link and useRouter
 
import Link from "next/link";
import { connection } from "next/server";
import { NavigationButtons } from "./navigation-buttons";
 
export default async function NavDemoPage() {
  await connection(); // Opt out of prerendering - Date.now() needs request time
  return (
    <main className="mx-auto max-w-2xl p-8">
      <h1 className="mb-6 font-bold text-3xl">Navigation Demo</h1>
 
      <div className="space-y-6">
        {/* Link Component - Soft Navigation */}
        <section className="rounded-lg border p-4">
          <h2 className="mb-4 font-semibold text-xl">
            Soft Navigation with Link
          </h2>
          <p className="mb-4 text-gray-600">
            These links use next/link for client-side transitions. Notice how
            the page does not fully reload.
          </p>
          <nav className="flex gap-4">
            <Link
              href="/nav-demo/page-a"
              className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
            >
              Go to Page A
            </Link>
            <Link
              href="/nav-demo/page-b"
              className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
            >
              Go to Page B
            </Link>
          </nav>
        </section>
 
        {/* Hard Navigation Comparison */}
        <section className="rounded-lg border p-4">
          <h2 className="mb-4 font-semibold text-xl">
            Hard Navigation (Avoid)
          </h2>
          <p className="mb-4 text-gray-600">
            Plain anchor tags cause full page reloads. Notice the browser
            refresh indicator.
          </p>
          <nav className="flex gap-4">
            <a
              href="/nav-demo/page-a"
              className="rounded border border-gray-300 px-4 py-2 text-gray-700 hover:bg-gray-50"
            >
              Hard nav to Page A
            </a>
          </nav>
        </section>
 
        {/* Programmatic Navigation with useRouter */}
        <section className="rounded-lg border p-4">
          <h2 className="mb-4 font-semibold text-xl">
            Programmatic Navigation
          </h2>
          <p className="mb-4 text-gray-600">
            useRouter enables navigation from event handlers and conditional
            logic.
          </p>
          <NavigationButtons />
        </section>
 
        {/* State Preservation Demo */}
        <section className="rounded-lg border p-4">
          <h2 className="mb-4 font-semibold text-xl">State Preservation</h2>
          <p className="mb-4 text-gray-600">
            Navigate to child pages and back. The layout state is preserved
            during soft navigation.
          </p>
          <div className="rounded bg-gray-100 p-4">
            <p className="text-gray-500 text-sm">
              Current timestamp: {Date.now()}
            </p>
            <p className="text-gray-500 text-sm">
              (This updates on hard navigation but not soft navigation from
              child pages back)
            </p>
          </div>
        </section>
      </div>
 
      {/* Key concepts summary */}
      <div className="mt-8 rounded bg-gray-100 p-4">
        <h3 className="mb-2 font-semibold">Key Concepts</h3>
        <ul className="list-inside list-disc space-y-1 text-gray-600 text-sm">
          <li>
            <strong>Soft navigation:</strong> Client-side transitions, preserves
            state
          </li>
          <li>
            <strong>Hard navigation:</strong> Full page reload, clears state
          </li>
          <li>
            <strong>Link component:</strong> Declarative soft navigation
          </li>
          <li>
            <strong>useRouter:</strong> Programmatic client-side navigation
          </li>
          <li>
            <strong>redirect():</strong> Server-side navigation (see below)
          </li>
        </ul>
      </div>
    </main>
  );
}

Step 2: Create Client Component for Programmatic Navigation

apps/web/src/app/nav-demo/navigation-buttons.tsx
"use client";
 
// Client component for programmatic navigation with useRouter
// Must be a Client Component because useRouter uses React hooks
 
import { useRouter } from "next/navigation";
 
export function NavigationButtons() {
  const router = useRouter();
 
  return (
    <div className="flex gap-4">
      {/* push: adds to history stack */}
      <button
        type="button"
        onClick={() => router.push("/nav-demo/page-a")}
        className="rounded bg-green-600 px-4 py-2 text-white hover:bg-green-700"
      >
        router.push()
      </button>
 
      {/* back: navigate to previous history entry */}
      <button
        type="button"
        onClick={() => router.back()}
        className="rounded border border-gray-300 px-4 py-2 text-gray-700 hover:bg-gray-50"
      >
        router.back()
      </button>
 
      {/* replace: replaces current history entry (no back button) */}
      <button
        type="button"
        onClick={() => router.replace("/nav-demo/page-b")}
        className="rounded bg-orange-600 px-4 py-2 text-white hover:bg-orange-700"
      >
        router.replace()
      </button>
    </div>
  );
}

Step 3: Create Child Pages for Navigation Testing

apps/web/src/app/nav-demo/page-a/page.tsx
import Link from "next/link";
 
export default function PageA() {
  return (
    <main className="mx-auto max-w-2xl p-8">
      <h1 className="mb-4 font-bold text-2xl">Page A</h1>
      <p className="mb-6 text-gray-600">
        You navigated here via soft navigation. The layout was preserved.
      </p>
      <nav className="flex gap-4">
        <Link
          href="/nav-demo"
          className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
        >
          Back to Demo
        </Link>
        <Link
          href="/nav-demo/page-b"
          className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
        >
          Go to Page B
        </Link>
      </nav>
    </main>
  );
}
apps/web/src/app/nav-demo/page-b/page.tsx
import Link from "next/link";
 
export default function PageB() {
  return (
    <main className="mx-auto max-w-2xl p-8">
      <h1 className="mb-4 font-bold text-2xl">Page B</h1>
      <p className="mb-6 text-gray-600">
        You navigated here via soft navigation. The layout was preserved.
      </p>
      <nav className="flex gap-4">
        <Link
          href="/nav-demo"
          className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
        >
          Back to Demo
        </Link>
        <Link
          href="/nav-demo/page-a"
          className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
        >
          Go to Page A
        </Link>
      </nav>
    </main>
  );
}

Step 4: Server-Side Navigation with redirect()

Use redirect() for server-side navigation, authentication checks, and after form submissions:

apps/web/src/app/protected/page.tsx
// Server-side redirect for authentication
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
 
export default async function ProtectedPage() {
  const session = await getSession();
 
  // Redirect unauthenticated users to login
  if (!session) {
    redirect("/login");
  }
 
  return (
    <main className="mx-auto max-w-2xl p-8">
      <h1 className="mb-4 font-bold text-2xl">Protected Content</h1>
      <p>Welcome, {session.user.name}!</p>
    </main>
  );
}
Demo Routes

We use /posts routes in apps/web to demonstrate navigation patterns. These demo routes are separate from the actual blog app (apps/blog) which has its own routing structure.

Step 5: Redirect After Server Action (Form Submission)

apps/web/src/app/posts/new/actions.ts
"use server";
 
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
 
export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;
  const content = formData.get("content") as string;
 
  // Save to database
  const post = await db.post.create({
    data: { title, content },
  });
 
  // Revalidate the posts list so it shows the new post
  revalidatePath("/posts");
 
  // Redirect to the newly created post
  // Server Actions use 303 status code by default
  redirect(`/posts/${post.slug}`);
}
apps/web/src/app/posts/new/page.tsx
import { createPost } from "./actions";
 
export default function NewPostPage() {
  return (
    <main className="mx-auto max-w-2xl p-8">
      <h1 className="mb-6 font-bold text-2xl">Create New Post</h1>
 
      <form action={createPost} className="space-y-4">
        <div>
          <label htmlFor="title" className="block font-medium text-gray-700">
            Title
          </label>
          <input
            type="text"
            id="title"
            name="title"
            required
            className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
          />
        </div>
 
        <div>
          <label htmlFor="content" className="block font-medium text-gray-700">
            Content
          </label>
          <textarea
            id="content"
            name="content"
            rows={5}
            required
            className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
          />
        </div>
 
        <button
          type="submit"
          className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
        >
          Create Post
        </button>
      </form>
    </main>
  );
}

Step 6: Conditional Redirect Based on Data

apps/web/src/app/users/[id]/page.tsx
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
 
async function fetchUser(id: string) {
  const res = await fetch(`https://api.example.com/users/${id}`);
  if (!res.ok) return null;
  return res.json();
}
 
interface PageProps {
  params: Promise<{ id: string }>;
}
 
export default async function UserPage({ params }: PageProps) {
  const { id } = await params;
  const user = await fetchUser(id);
 
  // Redirect to login if user not found (auth scenario)
  if (!user) {
    redirect("/login");
  }
 
  // Alternative: show 404 for missing resources
  // if (!user) {
  //   notFound();
  // }
 
  return (
    <main className="mx-auto max-w-2xl p-8">
      <h1 className="mb-4 font-bold text-2xl">{user.name}</h1>
      <p>{user.email}</p>
    </main>
  );
}

Key Decisions Explained

  1. Link over anchor tags: <Link> provides soft navigation (client-side transitions) that preserves state, enables prefetching, and feels faster. Plain <a> tags trigger hard navigation with full page reloads.

  2. useRouter for programmatic navigation: When you need to navigate based on events or conditions (button clicks, form submissions, conditional logic), use useRouter in a Client Component.

  3. redirect() for server-side navigation: Use redirect() in Server Components, Server Actions, and Route Handlers. Common uses include authentication checks and redirecting after mutations.

  4. type="button" on all buttons: Biome requires explicit type attributes on buttons. Without it, buttons default to type="submit" which can cause unintended form submissions.

  5. router.replace vs router.push: Use replace when you don't want users to navigate back (e.g., after login redirect). Use push for normal navigation where back button should work.

  6. revalidatePath before redirect: When redirecting after a mutation, call revalidatePath() first to ensure the destination page shows fresh data.

References