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:
- It intercepts
<a>clicks and callspushStateinstead of following the link normally - It listens to
popstateevents for back/forward navigation - It reads
window.location.pathnameand 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
| Pattern | Next.js file | Nuxt file | Matches |
|---|---|---|---|
| Static | app/about/page.tsx | pages/about.vue | /about |
| Dynamic segment | app/orders/[id]/page.tsx | pages/orders/[id].vue | /orders/42 |
| Optional dynamic | app/orders/[[id]]/page.tsx | pages/orders/[[id]].vue | /orders and /orders/42 |
| Catch-all | app/docs/[...slug]/page.tsx | pages/docs/[...slug].vue | /docs/a/b/c |
| Optional catch-all | app/docs/[[...slug]]/page.tsx | pages/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} />;
}
Navigation: Link and useRouter
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
| Concept | ASP.NET MVC | Next.js (App Router) | Nuxt |
|---|---|---|---|
| Route definition | MapControllerRoute or attributes | File 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.cshtml | layout.tsx (persists in DOM) | layouts/default.vue |
| Nested layout | _ViewStart + layout inheritance | Nested layout.tsx files | Nested layouts via <NuxtLayout> |
| Route guard | [Authorize] attribute | middleware.ts or page-level redirect | middleware/ directory |
| Programmatic nav | return RedirectToAction(...) | router.push(url) | navigateTo(url) |
| Link component | <a asp-action="..."> | <Link href="..."> | <NuxtLink :to="..."> |
| Route params | int id method parameter | params.id prop (string) | route.params.id (string) |
| Query string | string? tab method parameter | searchParams.tab prop | route.query.tab |
| Full page reload | Always | Never (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");
}, []);
}
Gotcha 3: The <Link> component prefetches aggressively
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:
-
Create a
layouts/app/layout.tsxthat renders a persistent<NavBar>with links to/ordersand/users. Verify that the NavBar does not re-mount when navigating between those sections (add aconsole.log("NavBar mounted")in auseEffect). -
Create
app/orders/[id]/page.tsx. Accessparams.id, convert it to a number, and render it. Add anotFound()call ifidisNaN. -
Create a route guard: add
middleware.tsthat checks for alogged-incookie. If the cookie is missing and the requested path is/orders/[id]/edit, redirect to/login?returnUrl=/orders/[id]/edit. -
On the
/loginpage, set the cookie via a form submit (just a client-sidedocument.cookiefor this exercise) and then redirect to thereturnUrlquery parameter value. -
Verify the
window is not definedproblem: try accessinglocalStoragedirectly in a Server Component, observe the error, then move the code into a"use client"component with auseEffect.
Quick Reference
| Task | Next.js | Nuxt |
|---|---|---|
| Navigate with link | <Link href="/path">text</Link> | <NuxtLink to="/path">text</NuxtLink> |
| Navigate programmatically | router.push("/path") | navigateTo("/path") |
| Replace current history entry | router.replace("/path") | navigateTo("/path", { replace: true }) |
| Go back | router.back() | router.back() |
| Read route param | params.id (Server Component) / useParams() (Client) | useRoute().params.id |
| Read query string | searchParams.tab (Server) / useSearchParams() (Client) | useRoute().query.tab |
| Redirect in server code | redirect("/path") from next/navigation | navigateTo("/path") in middleware |
| Route middleware / guard | middleware.ts at project root | middleware/name.ts |
| Apply middleware to page | Configured in matcher or globally | definePageMeta({ middleware: ["name"] }) |
| Layout for a section | layout.tsx in the directory | layouts/name.vue + definePageMeta |
| Catch-all route | [...slug] folder | [...slug].vue |
| 404 page | not-found.tsx | error.vue |
| Generate static paths | generateStaticParams() export | nitro.routeRules or useAsyncData |
| Opt into client rendering | "use client" at top of file | All .vue components are client-capable |
| Prefetch link | Default in <Link> | Default in <NuxtLink> |
| Disable prefetch | <Link prefetch={false}> | <NuxtLink no-prefetch> |
Further Reading
- Next.js App Router documentation — covers every routing concept with examples
- Next.js middleware — configuration, matchers, and reading cookies/headers
- Nuxt routing — file-system routing, dynamic routes, and navigation
- MDN: History API — the browser primitive that all client-side routing builds on
- Article 3.12 — SSR and Hydration — explains how these client-side routes are server-rendered for SEO and performance