Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Client-Side Routing and Navigation

For .NET engineers who know: ASP.NET MVC routing (MapControllerRoute, attribute routing), [Authorize], IActionFilter, Razor layouts (_Layout.cshtml) You’ll learn: How SPAs intercept URL changes to swap components without a server round-trip, and how Next.js and Nuxt structure the file system to define routes Time: 10-15 minutes

The .NET Way (What You Already Know)

In ASP.NET MVC, every URL is a server request. The browser sends an HTTP GET to /orders/42, the server matches it against its route table, executes the controller action, renders a Razor view, and returns a full HTML page. The browser then does a full page replacement: the old DOM is discarded and the new one is painted from scratch.

// Startup.cs — route table
app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

// or attribute routing on the controller
[Route("orders")]
public class OrdersController : Controller
{
    [HttpGet("{id:int}")]
    public IActionResult Detail(int id)
    {
        var order = _orderService.Get(id);
        return View(order); // renders Orders/Detail.cshtml
    }
}

Route guards are middleware or action filters:

// [Authorize] attribute — redirects to /Account/Login if not authenticated
[Authorize]
[HttpGet("{id:int}")]
public IActionResult Detail(int id) { ... }

// Or globally via middleware
app.UseAuthorization();

Layouts are Razor layout files:

<!-- Views/Shared/_Layout.cshtml -->
<!DOCTYPE html>
<html>
<head><title>@ViewData["Title"]</title></head>
<body>
    <nav><!-- shared nav --></nav>
    @RenderBody()   <!-- page content goes here -->
    @RenderSection("Scripts", required: false)
</body>
</html>

This model is simple to reason about: every page is independent, the server owns the routing logic, and there is no shared JavaScript state between pages (unless you deliberately add it). The cost is latency — every navigation is a network round-trip even if 90% of the page is identical to what the user is already looking at.

The SPA Way

How Client-Side Routing Works

In a Single-Page Application, the browser loads one HTML file and one JavaScript bundle once. After that, all navigation is handled in JavaScript — no server requests for page changes.

The mechanism is the browser’s History API:

// The History API — what routing libraries use under the hood
window.history.pushState({ orderId: 42 }, "", "/orders/42");
// This changes the URL bar to /orders/42 WITHOUT triggering an HTTP request
// The browser does not reload the page

window.history.replaceState(null, "", "/orders/42");
// Same, but replaces the current history entry instead of pushing a new one
// (back button won't go back to the previous URL)

window.addEventListener("popstate", (event) => {
  // Fires when the user hits the back or forward button
  // event.state contains what you passed to pushState
  renderPageForCurrentUrl();
});

A routing library sits on top of this API:

  1. It intercepts <a> clicks and calls pushState instead of following the link normally
  2. It listens to popstate events for back/forward navigation
  3. It reads window.location.pathname and renders the matching component

The server still needs to cooperate for one scenario: if the user pastes /orders/42 into the address bar, the browser sends a real HTTP request to that path. The server must respond with the same index.html for all routes — otherwise the user gets a 404. This is why most SPA deployment configurations include a catch-all rule:

# nginx.conf — catch-all for SPAs
location / {
    try_files $uri $uri/ /index.html;
}

Next.js and Nuxt handle this automatically because they control the server.

Next.js: File-System Routing with the App Router

Next.js maps the file system directly to routes. You do not write a route table. You create files in specific directories and Next.js generates the routes from the directory structure.

app/
  page.tsx              → /
  layout.tsx            → root layout (wraps all pages)
  orders/
    page.tsx            → /orders
    layout.tsx          → layout for /orders and all children
    [id]/
      page.tsx          → /orders/42, /orders/99, etc.
      edit/
        page.tsx        → /orders/42/edit
  (auth)/               → route group — does NOT add a URL segment
    login/
      page.tsx          → /login
    register/
      page.tsx          → /register
  [...slug]/
    page.tsx            → catch-all: /anything/nested/deep

The parentheses in (auth) create a route group — a folder for organizing files that does not create a URL segment. It is the Next.js way to have a different layout for a section of the app (like unauthenticated pages) without changing the URL structure.

// app/orders/[id]/page.tsx — dynamic route
// The segment name in brackets becomes a prop named "params"

interface PageProps {
  params: { id: string }; // always string — URL params are strings
  searchParams: { [key: string]: string | string[] | undefined };
}

export default async function OrderDetailPage({ params, searchParams }: PageProps) {
  // In the App Router, page.tsx is a Server Component by default
  // You can fetch data directly here — no useEffect, no loading state
  const order = await orderService.getById(Number(params.id));

  if (!order) {
    notFound(); // renders the nearest not-found.tsx
  }

  return (
    <main>
      <h1>Order #{order.id}</h1>
      <p>Customer: {order.customerName}</p>
    </main>
  );
}

// Generate static paths for SSG (Article 3.12)
export async function generateStaticParams() {
  const orders = await orderService.getAll();
  return orders.map((order) => ({ id: String(order.id) }));
}
// app/layout.tsx — root layout, equivalent to _Layout.cshtml
import type { ReactNode } from "react";

interface LayoutProps {
  children: ReactNode;
}

export default function RootLayout({ children }: LayoutProps) {
  return (
    <html lang="en">
      <body>
        <nav>
          {/* Navigation renders once and persists across all page navigations */}
          <NavBar />
        </nav>
        <main>{children}</main>
      </body>
    </html>
  );
}

Layouts are persistent — unlike Razor’s _Layout.cshtml which re-renders the entire layout HTML on every page request, Next.js layouts are mounted once and stay in the DOM as you navigate between child routes. React maintains their state. This is why navigation feels instant after the initial load.

Nuxt: File-System Routing for Vue

Nuxt uses the same file-system convention, inside a pages/ directory:

pages/
  index.vue             → /
  orders/
    index.vue           → /orders
    [id].vue            → /orders/42
    [id]/
      edit.vue          → /orders/42/edit
  [...slug].vue         → catch-all
layouts/
  default.vue           → applied to all pages
  auth.vue              → alternative layout for auth pages
<!-- pages/orders/[id].vue -->
<script setup lang="ts">
const route = useRoute();
const id = Number(route.params.id);

// useAsyncData is Nuxt's data fetching primitive for SSR
const { data: order, error } = await useAsyncData(
  `order-${id}`,
  () => $fetch(`/api/orders/${id}`)
);
</script>

<template>
  <div v-if="order">
    <h1>Order #{{ order.id }}</h1>
    <p>Customer: {{ order.customerName }}</p>
  </div>
  <div v-else-if="error">Failed to load order.</div>
</template>

Dynamic Routes and Catch-All Routes

PatternNext.js fileNuxt fileMatches
Staticapp/about/page.tsxpages/about.vue/about
Dynamic segmentapp/orders/[id]/page.tsxpages/orders/[id].vue/orders/42
Optional dynamicapp/orders/[[id]]/page.tsxpages/orders/[[id]].vue/orders and /orders/42
Catch-allapp/docs/[...slug]/page.tsxpages/docs/[...slug].vue/docs/a/b/c
Optional catch-allapp/docs/[[...slug]]/page.tsxpages/docs/[[...slug]].vue/docs and /docs/a/b/c

Route Parameters and Query Strings

// Next.js — reading route params and query strings in a page component
export default function OrderPage({
  params,
  searchParams,
}: {
  params: { id: string };
  searchParams: { tab?: string; page?: string };
}) {
  const orderId = Number(params.id);              // /orders/42 → 42
  const activeTab = searchParams.tab ?? "details"; // ?tab=history → "history"
  const page = Number(searchParams.page ?? "1");   // ?page=3 → 3

  return <OrderDetail id={orderId} tab={activeTab} page={page} />;
}
// In a Client Component — use the useSearchParams hook
"use client";
import { useSearchParams, useParams } from "next/navigation";

function OrderTabs() {
  const params = useParams<{ id: string }>();
  const searchParams = useSearchParams();

  const tab = searchParams.get("tab") ?? "details";
  const orderId = Number(params.id);

  return <TabBar active={tab} orderId={orderId} />;
}

In HTML, <a href="/orders"> causes a full page reload. In Next.js and Nuxt, you use framework-provided components that intercept the click and use the History API instead:

// Next.js — Link component (preferred for navigation users can see)
import Link from "next/link";

function OrderList({ orders }: { orders: Order[] }) {
  return (
    <ul>
      {orders.map((order) => (
        <li key={order.id}>
          {/* Next.js prefetches this route when the link is visible in the viewport */}
          <Link href={`/orders/${order.id}`}>Order #{order.id}</Link>
        </li>
      ))}
    </ul>
  );
}
// Next.js — useRouter for programmatic navigation (after form submission, etc.)
"use client";
import { useRouter } from "next/navigation";

function CreateOrderForm() {
  const router = useRouter();

  const onSubmit = async (data: CreateOrderFormValues) => {
    const order = await orderApi.create(data);

    // Programmatic navigation — equivalent to Response.Redirect() in C#
    router.push(`/orders/${order.id}`);

    // router.replace() — does not add to history (like RedirectToAction with replace)
    router.replace("/dashboard");

    // router.back() — equivalent to history.go(-1)
    router.back();

    // router.refresh() — re-fetches server data for current route without full reload
    router.refresh();
  };
}
<!-- Nuxt — NuxtLink and useRouter -->
<template>
  <ul>
    <li v-for="order in orders" :key="order.id">
      <NuxtLink :to="`/orders/${order.id}`">Order #{{ order.id }}</NuxtLink>
    </li>
  </ul>
</template>

<script setup lang="ts">
const router = useRouter();

async function createAndNavigate(data: CreateOrderFormValues) {
  const order = await orderApi.create(data);
  await router.push(`/orders/${order.id}`);
}
</script>

Route Guards: Middleware (the [Authorize] equivalent)

In ASP.NET, [Authorize] is an attribute on controllers or actions. In Next.js, route protection is handled by middleware — a file at the root of your project that runs before every request:

// middleware.ts — runs on every matching request, before the page renders
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const token = request.cookies.get("auth-token")?.value;
  const isAuthRoute = request.nextUrl.pathname.startsWith("/auth");
  const isApiRoute = request.nextUrl.pathname.startsWith("/api");

  // If no token and trying to access a protected route
  if (!token && !isAuthRoute && !isApiRoute) {
    const loginUrl = new URL("/auth/login", request.url);
    loginUrl.searchParams.set("returnUrl", request.nextUrl.pathname);
    return NextResponse.redirect(loginUrl);
    // Equivalent to [Authorize]'s redirect to /Account/Login?ReturnUrl=...
  }

  // If has token and trying to access auth pages, redirect to dashboard
  if (token && isAuthRoute) {
    return NextResponse.redirect(new URL("/dashboard", request.url));
  }

  return NextResponse.next(); // allow the request through
}

// Configure which paths the middleware runs on
export const config = {
  matcher: [
    // Exclude static files, images, Next.js internals
    "/((?!_next/static|_next/image|favicon.ico).*)",
  ],
};

For more granular per-page authorization (checking specific roles, not just “is logged in”), handle it in the page component itself:

// app/admin/page.tsx — page-level authorization check
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/auth";

export default async function AdminPage() {
  const user = await getCurrentUser();

  if (!user) {
    redirect("/auth/login");
  }

  if (!user.roles.includes("admin")) {
    redirect("/unauthorized");
  }

  return <AdminDashboard user={user} />;
}

In Nuxt, route middleware lives in the middleware/ directory:

// middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
  const authStore = useAuthStore();

  if (!authStore.isAuthenticated) {
    return navigateTo(`/login?returnUrl=${to.fullPath}`);
  }
});
<!-- Apply middleware to a specific page -->
<script setup>
definePageMeta({
  middleware: ["auth"],  // runs the auth middleware before this page renders
});
</script>

Nested Routes and Layouts

In Next.js, every layout.tsx in a directory wraps all routes nested inside it. This creates nested layouts that can share state:

app/
  layout.tsx           → outer layout (html, body, NavBar)
  orders/
    layout.tsx         → inner layout for /orders/* (sidebar, breadcrumbs)
    page.tsx           → /orders — rendered inside both layouts
    [id]/
      page.tsx         → /orders/42 — also rendered inside both layouts
// app/orders/layout.tsx — layout for the /orders section
export default function OrdersLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="orders-section">
      <aside>
        <OrdersSidebar />  {/* persists as you navigate between /orders pages */}
      </aside>
      <section>{children}</section>
    </div>
  );
}

This nesting is more powerful than Razor’s _Layout.cshtml inheritance: each layout mounts once and maintains React state. An accordion open in OrdersSidebar stays open as the user navigates from /orders to /orders/42.

Key Differences

ConceptASP.NET MVCNext.js (App Router)Nuxt
Route definitionMapControllerRoute or attributesFile system (app/**/*.tsx)File system (pages/**/*.vue)
Dynamic segment{id} in route template[id] folder/file name[id].vue file name
Catch-all{*path}[...slug][...slug].vue
Layout_Layout.cshtmllayout.tsx (persists in DOM)layouts/default.vue
Nested layout_ViewStart + layout inheritanceNested layout.tsx filesNested layouts via <NuxtLayout>
Route guard[Authorize] attributemiddleware.ts or page-level redirectmiddleware/ directory
Programmatic navreturn RedirectToAction(...)router.push(url)navigateTo(url)
Link component<a asp-action="..."><Link href="..."><NuxtLink :to="...">
Route paramsint id method parameterparams.id prop (string)route.params.id (string)
Query stringstring? tab method parametersearchParams.tab proproute.query.tab
Full page reloadAlwaysNever (client-side navigation)Never (client-side navigation)

Gotchas for .NET Engineers

Gotcha 1: Route parameters are always strings

In ASP.NET, route parameters are automatically converted to the method parameter’s declared type. int id in a controller action receives 42 as an integer. In Next.js and Nuxt, params.id is always a string. You must convert it yourself.

// WRONG — comparison will always fail (42 !== "42")
const order = orders.find((o) => o.id === params.id);

// CORRECT — convert to the expected type
const orderId = Number(params.id);
// Or, to guard against NaN:
const orderId = parseInt(params.id, 10);
if (isNaN(orderId)) notFound();

const order = orders.find((o) => o.id === orderId);

Gotcha 2: SEO and the “window is not defined” problem

Client-side routing means the server initially sends a nearly empty HTML file, and the browser renders everything with JavaScript. Search engine crawlers may not execute JavaScript, so they see little content. This is why pure SPAs (like Create React App) rank poorly for content pages.

Next.js and Nuxt solve this with Server-Side Rendering (covered in Article 3.12). But even in SSR mode, code that references browser globals (window, document, localStorage) will crash when it runs on the server — because those globals do not exist in Node.js.

// WRONG — crashes on the server
function getStoredTheme() {
  return localStorage.getItem("theme"); // ReferenceError: localStorage is not defined
}

// CORRECT — guard with typeof check
function getStoredTheme() {
  if (typeof window === "undefined") return "light"; // server-side fallback
  return localStorage.getItem("theme") ?? "light";
}

// CORRECT — use useEffect which only runs in the browser
"use client";
import { useState, useEffect } from "react";

function ThemeToggle() {
  const [theme, setTheme] = useState("light");

  useEffect(() => {
    // This only runs in the browser, after hydration
    setTheme(localStorage.getItem("theme") ?? "light");
  }, []);
}

Next.js’s <Link> component automatically prefetches the linked route’s JavaScript when the link enters the viewport. This is a performance optimization — navigation feels instant because the code is already downloaded. But on pages with hundreds of links, this can cause significant bandwidth usage and CPU time on slow devices.

// Disable prefetching for links that are rarely clicked
<Link href="/admin/audit-log" prefetch={false}>
  Audit Log
</Link>

// The prefetch behavior in the App Router:
// - In production: prefetches when link is in the viewport
// - In development: no prefetching (to avoid slowdowns while iterating)

Gotcha 4: Client Components and Server Components are not interchangeable

In Next.js’s App Router, components are Server Components by default. Server Components run on the server only and cannot use React hooks or browser APIs. To opt into client-side rendering, add "use client" at the top of the file.

// app/orders/page.tsx — Server Component (no "use client")
// Can use async/await, fetch(), server-only imports
// Cannot use useState, useEffect, onClick handlers, or browser APIs

export default async function OrdersPage() {
  const orders = await orderService.getAll(); // direct DB call OK here
  return <OrderList orders={orders} />;
}
// components/OrderList.tsx — Client Component
"use client";
// Can use useState, useEffect, onClick, useRouter, etc.
// Cannot use server-only imports (db clients, fs, etc.)

import { useState } from "react";

export function OrderList({ orders }: { orders: Order[] }) {
  const [selected, setSelected] = useState<number | null>(null);

  return (
    <ul>
      {orders.map((o) => (
        <li key={o.id} onClick={() => setSelected(o.id)}>
          {o.id === selected ? "[selected] " : ""}{o.customerName}
        </li>
      ))}
    </ul>
  );
}

The "use client" directive is not “turn off server rendering for this page.” It is “this component requires access to browser APIs or React state.” The component still gets server-rendered HTML for SEO and then hydrated on the client. Think of it more like a capabilities declaration.

Hands-On Exercise

Start with a fresh Next.js project (npx create-next-app@latest --typescript) and implement the following routing structure:

Route structure to build:

/                        → home page with links to Orders and Users
/orders                  → list of mock orders
/orders/[id]             → detail page for a single order
/orders/[id]/edit        → edit form (protected — redirect to /login if not logged in)
/users                   → list of users (uses the same layout as /orders)
/login                   → login page

Tasks:

  1. Create a layouts/app/layout.tsx that renders a persistent <NavBar> with links to /orders and /users. Verify that the NavBar does not re-mount when navigating between those sections (add a console.log("NavBar mounted") in a useEffect).

  2. Create app/orders/[id]/page.tsx. Access params.id, convert it to a number, and render it. Add a notFound() call if id is NaN.

  3. Create a route guard: add middleware.ts that checks for a logged-in cookie. If the cookie is missing and the requested path is /orders/[id]/edit, redirect to /login?returnUrl=/orders/[id]/edit.

  4. On the /login page, set the cookie via a form submit (just a client-side document.cookie for this exercise) and then redirect to the returnUrl query parameter value.

  5. Verify the window is not defined problem: try accessing localStorage directly in a Server Component, observe the error, then move the code into a "use client" component with a useEffect.

Quick Reference

TaskNext.jsNuxt
Navigate with link<Link href="/path">text</Link><NuxtLink to="/path">text</NuxtLink>
Navigate programmaticallyrouter.push("/path")navigateTo("/path")
Replace current history entryrouter.replace("/path")navigateTo("/path", { replace: true })
Go backrouter.back()router.back()
Read route paramparams.id (Server Component) / useParams() (Client)useRoute().params.id
Read query stringsearchParams.tab (Server) / useSearchParams() (Client)useRoute().query.tab
Redirect in server coderedirect("/path") from next/navigationnavigateTo("/path") in middleware
Route middleware / guardmiddleware.ts at project rootmiddleware/name.ts
Apply middleware to pageConfigured in matcher or globallydefinePageMeta({ middleware: ["name"] })
Layout for a sectionlayout.tsx in the directorylayouts/name.vue + definePageMeta
Catch-all route[...slug] folder[...slug].vue
404 pagenot-found.tsxerror.vue
Generate static pathsgenerateStaticParams() exportnitro.routeRules or useAsyncData
Opt into client rendering"use client" at top of fileAll .vue components are client-capable
Prefetch linkDefault in <Link>Default in <NuxtLink>
Disable prefetch<Link prefetch={false}><NuxtLink no-prefetch>

Further Reading