| name | routing-patterns |
| description | Explains client-side routing, server-side routing, file-based routing, and navigation patterns in web applications. Use when implementing routing, understanding how navigation works in SPAs vs MPAs, or configuring routes in meta-frameworks. |
Routing Patterns
Overview
Routing determines how URLs map to content and how navigation between pages works. The approach differs significantly between traditional server-rendered apps and modern SPAs.
Server-Side Routing
Server determines what to render based on the URL.
How It Works
1. User navigates to /about
2. Browser sends request to server
3. Server matches /about to handler
4. Server renders HTML
5. Server sends complete HTML response
6. Browser loads new page (full reload)
Characteristics
Request: GET /products/123
Server:
Route table:
/ → HomeController
/about → AboutController
/products/:id → ProductController ← matches
ProductController:
- Fetches product 123
- Renders HTML
- Returns response
Pros and Cons
| Pros | Cons |
|---|
| SEO-friendly (full HTML) | Full page reload |
| Simple mental model | Slower navigation |
| Works without JavaScript | State lost between pages |
| Direct URL to content mapping | Server must handle every route |
Client-Side Routing
JavaScript handles routing in the browser, no server round-trip for navigation.
How It Works
1. Initial: Browser loads app shell + JS
2. User clicks link
3. JavaScript intercepts click (preventDefault)
4. JS updates URL using History API
5. JS renders new view/component
6. No server request for HTML
The History API
history.pushState({ page: 'about' }, '', '/about');
history.replaceState({ page: 'home' }, '', '/');
window.addEventListener('popstate', (event) => {
renderRoute(window.location.pathname);
});
Basic Router Implementation
class Router {
constructor() {
this.routes = {};
window.addEventListener('popstate', () => this.handleRoute());
}
register(path, component) {
this.routes[path] = component;
}
navigate(path) {
history.pushState({}, '', path);
this.handleRoute();
}
handleRoute() {
const path = window.location.pathname;
const component = this.routes[path] || this.routes['/404'];
component.render();
}
}
router.register('/about', AboutComponent);
router.register('/products/:id', ProductComponent);
Link Interception
document.addEventListener('click', (e) => {
const link = e.target.closest('a');
if (link && link.href.startsWith(window.location.origin)) {
e.preventDefault();
router.navigate(link.pathname);
}
});
File-Based Routing
Route structure derived from file system layout.
Common Patterns
File System URL
────────────────────────────── ─────────────
pages/index.tsx → /
pages/about.tsx → /about
pages/blog/index.tsx → /blog
pages/blog/[slug].tsx → /blog/:slug
pages/docs/[...path].tsx → /docs/*
pages/[category]/[id].tsx → /:category/:id
Framework Implementations
Next.js (App Router):
app/
├── page.tsx → /
├── about/page.tsx → /about
├── blog/[slug]/page.tsx → /blog/:slug
└── (marketing)/ → Route group (no URL segment)
└── pricing/page.tsx → /pricing
SvelteKit:
src/routes/
├── +page.svelte → /
├── about/+page.svelte → /about
├── blog/[slug]/+page.svelte → /blog/:slug
└── [[optional]]/+page.svelte → / or /:optional
Remix:
app/routes/
├── _index.tsx → /
├── about.tsx → /about
├── blog._index.tsx → /blog
├── blog.$slug.tsx → /blog/:slug
└── $.tsx → Catch-all (splat)
Dynamic Routes
Parameter Types
Static: /about → Exact match
Dynamic: /blog/[slug] → Single parameter
Catch-all: /docs/[...path] → Multiple segments
Optional: /[[locale]]/ → With or without parameter
Parameter Access
Concept (framework-agnostic):
Route Groups and Layouts
Nested Layouts
app/
├── layout.tsx → Root layout (applies to all)
├── (shop)/
│ ├── layout.tsx → Shop layout (header, cart)
│ ├── products/page.tsx → /products (uses shop layout)
│ └── cart/page.tsx → /cart (uses shop layout)
└── (marketing)/
├── layout.tsx → Marketing layout (different nav)
├── about/page.tsx → /about (uses marketing layout)
└── pricing/page.tsx → /pricing (uses marketing layout)
Parallel Routes
Load multiple views simultaneously:
app/
├── @sidebar/
│ └── page.tsx → Sidebar content
├── @main/
│ └── page.tsx → Main content
└── layout.tsx → Renders both in parallel
Intercepting Routes
Handle routes differently in different contexts:
app/
├── photos/[id]/page.tsx → Full page view
├── @modal/(.)photos/[id]/ → Modal overlay (when navigating internally)
│ └── page.tsx
└── feed/page.tsx → Grid with links to photos
Navigation Patterns
Declarative Navigation
<Link href="/about">About</Link>
<router-link to="/about">About</router-link>
<a href="/about">About</a> <!-- Enhanced automatically -->
Programmatic Navigation
const navigate = useNavigate();
navigate('/dashboard');
const router = useRouter();
router.push('/dashboard');
router.push('/dashboard');
import { goto } from '$app/navigation';
goto('/dashboard');
Navigation Options
router.replace('/login');
router.push('/search?q=term');
router.push('/docs#section');
router.push('/page', { scroll: true });
router.push('/page', { shallow: true });
Route Protection
Authentication Guards
export function middleware(request) {
const token = request.cookies.get('auth-token');
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
export async function loader({ request }) {
const user = await getUser(request);
if (!user) {
throw redirect('/login');
}
return { user };
}
Authorization Patterns
if (!user.roles.includes('admin')) {
redirect('/unauthorized');
}
if (!features.newDashboard) {
redirect('/legacy-dashboard');
}
URL State Management
Query Parameters
const searchParams = useSearchParams();
const query = searchParams.get('q');
const params = new URLSearchParams(searchParams);
params.set('page', '2');
router.push(`/products?${params.toString()}`);
Hash-Based State
useEffect(() => {
const hash = window.location.hash;
if (hash) {
document.querySelector(hash)?.scrollIntoView();
}
}, []);
State in URL vs Component
URL State (shareable, bookmarkable):
- Current page, filters, search query
- Tab selection
- Modal open state (sometimes)
Component State (ephemeral):
- Form input before submission
- Hover/focus states
- Temporary UI state
Performance Patterns
Prefetching
<Link href="/about" prefetch={true}>About</Link>
router.prefetch('/dashboard');
Code Splitting by Route
const Dashboard = lazy(() => import('./Dashboard'));
<Route path="/dashboard" element={
<Suspense fallback={<Loading />}>
<Dashboard />
</Suspense>
} />
Route Transitions
const navigation = useNavigation();
{navigation.state === 'loading' && <LoadingBar />}
Common Routing Pitfalls
1. Breaking the Back Button
router.replace('/page');
router.push('/page');
2. Not Handling 404s
3. Client-Only Routes in SSR
4. Hash URLs for SEO Content
Deep Dive: Understanding Routing From First Principles
What is a URL? Anatomy of Web Addresses
Before understanding routing, you must understand URLs (Uniform Resource Locators):
https://www.example.com:443/products/shoes?color=red&size=10#reviews
│ │ │ │ │ │
│ │ │ │ │ └─ Fragment (client-only, not sent to server)
│ │ │ │ │
│ │ │ │ └─ Query String (key-value pairs)
│ │ │ │
│ │ │ └─ Pathname (the route)
│ │ │
│ │ └─ Port (usually implicit: 80 for http, 443 for https)
│ │
│ └─ Hostname (domain)
│
└─ Protocol (scheme)
Key insight: Only protocol, hostname, port, pathname, and query are sent to the server. The fragment (#hash) stays in the browser.
The History of Web Routing
Era 1: File-Based (1990s)
URL: /products/shoes.html
Server: Find file at /var/www/products/shoes.html, return it
URLs literally mapped to files on disk.
Era 2: Dynamic Routing (2000s)
URL: /products.php?id=123
Server: Execute products.php, which reads $_GET['id'] = 123
Server-side scripts processed parameters.
Era 3: RESTful Routes (2010s)
URL: /products/123
Server: Route pattern /products/:id matches, extract id=123
Clean URLs with pattern matching.
Era 4: Client-Side Routing (2010s-present)
URL: /products/123
Client: JavaScript intercepts, renders Product component with id=123
Server: May never see this request (after initial load)
How Server-Side Routing Actually Works
When a server receives a request, it must decide what to do:
class Router {
constructor() {
this.routes = [];
}
get(pattern, handler) {
this.routes.push({
method: 'GET',
pattern: this.parsePattern(pattern),
handler
});
}
parsePattern(pattern) {
const regexStr = pattern
.replace(/:([^/]+)/g, '([^/]+)')
.replace(/\//g, '\\/');
return new RegExp(`^${regexStr}$`);
}
handle(req, res) {
const { method, url } = req;
const pathname = new URL(url, 'http://localhost').pathname;
for (const route of this.routes) {
if (route.method !== method) continue;
const match = pathname.match(route.pattern);
if (match) {
req.params = this.extractParams(route.pattern, match);
return route.handler(req, res);
}
}
res.status(404).send('Not Found');
}
}
Route matching order matters:
app.get('/users/new', newUserHandler);
app.get('/users/:id', getUserHandler);
How Client-Side Routing Intercepts Browser Behavior
The browser has default navigation behavior. Client-side routing overrides it:
document.addEventListener('click', (event) => {
const anchor = event.target.closest('a');
if (!anchor) return;
const url = new URL(anchor.href);
if (url.origin !== window.location.origin) return;
if (anchor.hasAttribute('download')) return;
if (anchor.target === '_blank') return;
if (event.metaKey || event.ctrlKey) return;
event.preventDefault();
navigateTo(url.pathname + url.search + url.hash);
});
function navigateTo(path) {
window.history.pushState({ path }, '', path);
renderRoute(path);
}
The History API in Detail
The History API is what makes SPAs possible without hash URLs:
history.pushState({ page: 'about' }, '', '/about');
history.pushState({ page: 'contact' }, '', '/contact');
Handling popstate:
window.addEventListener('popstate', (event) => {
const currentPath = window.location.pathname;
renderRoute(currentPath);
});
Hash Routing vs History Routing
Before the History API (HTML5, ~2010), SPAs used hash-based routing:
window.location.hash = '#/about';
window.addEventListener('hashchange', () => {
const route = window.location.hash.slice(1);
renderRoute(route);
});
History routing advantages:
history.pushState({}, '', '/about');
File-Based Routing: How It Works Internally
Meta-frameworks use file system as routing configuration:
const routes = [
{ pattern: /^\/$/, component: () => import('./app/page.tsx') },
{ pattern: /^\/about$/, component: () => import('./app/about/page.tsx') },
{ pattern: /^\/blog\/([^/]+)$/, component: () => import('./app/blog/[slug]/page.tsx') },
];
Why file-based routing became popular:
EXPLICIT ROUTING (React Router, Express):
- Route definitions separate from components
- Easy to have mismatched routes/files
- Must manually update both
// routes.tsx
<Route path="/products/:id" component={ProductPage} />
// ProductPage.tsx (might be anywhere)
export function ProductPage() { ... }
FILE-BASED ROUTING:
- Location IS the route
- Impossible to have orphan components
- Adding page = adding route automatically
// app/products/[id]/page.tsx (location defines route)
export default function ProductPage() { ... }
Route Parameters: Extraction and Typing
Understanding how parameters flow from URL to code:
const match = url.match(pattern);
const params = {
category: 'shoes',
productId: 'nike-air-max'
};
function ProductReviewsPage({ params }) {
console.log(params.category);
console.log(params.productId);
}
Catch-all routes:
const params = {
path: ['getting-started', 'installation', 'windows']
};
Nested Routes and Layouts: The Component Tree
Nested routing creates component hierarchies:
URL: /dashboard/settings/profile
Route hierarchy:
app/
├── layout.tsx ← Root layout (nav, footer)
└── dashboard/
├── layout.tsx ← Dashboard layout (sidebar)
└── settings/
├── layout.tsx ← Settings layout (tabs)
└── profile/
└── page.tsx ← Profile content
Renders as:
<RootLayout> {/* Always present */}
<DashboardLayout> {/* Present for /dashboard/* */}
<SettingsLayout> {/* Present for /dashboard/settings/* */}
<ProfilePage /> {/* The actual content */}
</SettingsLayout>
</DashboardLayout>
</RootLayout>
Why nested layouts matter:
function DashboardSettingsProfilePage() {
return (
<RootLayout>
<DashboardSidebar />
<SettingsTabs />
<ProfileForm /> {/* Actual unique content */}
</RootLayout>
);
}
function DashboardSettingsSecurityPage() {
return (
<RootLayout> {/* Duplicated */}
<DashboardSidebar /> {/* Duplicated */}
<SettingsTabs /> {/* Duplicated */}
<SecurityForm /> {/* Actual unique content */}
</RootLayout>
);
}
Route Guards and Middleware: The Request Pipeline
Routes often need protection or preprocessing:
function loggingMiddleware(request, next) {
console.log(`${request.method} ${request.url}`);
return next(request);
}
function authMiddleware(request, next) {
const token = request.cookies.get('auth-token');
if (!token) {
return redirect('/login');
}
request.user = decodeToken(token);
return next(request);
}
function adminMiddleware(request, next) {
if (!request.user.isAdmin) {
return redirect('/unauthorized');
}
return next(request);
}
function adminDashboardHandler(request) {
return renderPage(<AdminDashboard user={request.user} />);
}
Prefetching: How Routers Optimize Navigation
Modern routers predict user navigation:
<Link href="/about" prefetch>About</Link>
link.addEventListener('mouseenter', () => {
router.prefetch('/about');
});
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const href = entry.target.getAttribute('href');
router.prefetch(href);
}
});
});
document.querySelectorAll('a[prefetch]').forEach((link) => {
observer.observe(link);
});
Scroll Restoration: The Hidden Complexity
When navigating, browsers must manage scroll position:
function navigateTo(path) {
const scrollPosition = window.scrollY;
history.replaceState(
{ ...history.state, scrollY: scrollPosition },
''
);
history.pushState({ path, scrollY: 0 }, '', path);
window.scrollTo(0, 0);
}
window.addEventListener('popstate', (event) => {
renderRoute(window.location.pathname);
if (event.state?.scrollY !== undefined) {
requestAnimationFrame(() => {
window.scrollTo(0, event.state.scrollY);
});
}
});
The Edge Cases Every Router Must Handle
For Framework Authors: Building Routing Systems
Implementation Note: The patterns and code examples below represent one proven approach to building routing systems. Different frameworks take different approaches—React Router uses a declarative model, Next.js uses file-based routing, and SvelteKit combines both. The direction shown here provides core concepts that most routers share. Adapt these patterns based on your framework's component model, build pipeline, and developer experience goals.
Implementing a Route Matcher
class RouteMatcher {
constructor() {
this.routes = [];
}
add(pattern, handler, meta = {}) {
const { regex, paramNames, score } = this.compilePattern(pattern);
this.routes.push({ pattern, regex, paramNames, handler, meta, score });
this.routes.sort((a, b) => b.score - a.score);
}
compilePattern(pattern) {
const paramNames = [];
let score = 0;
const regexStr = pattern
.split('/')
.filter(Boolean)
.map(segment => {
if (segment.startsWith('[...') && segment.endsWith(']')) {
paramNames.push(segment.slice(4, -1));
score += 1;
return '(.+)';
}
if (segment.startsWith('[[') && segment.endsWith(']]')) {
paramNames.push(segment.slice(2, -2));
score += 5;
return '([^/]*)';
}
if (segment.startsWith('[') && segment.endsWith(']')) {
paramNames.push(segment.slice(1, -1));
score += 10;
return '([^/]+)';
}
score += 100;
return escapeRegex(segment);
})
.join('/');
return {
regex: new RegExp(`^/${regexStr}/?$`),
paramNames,
score,
};
}
match(pathname) {
for (const route of this.routes) {
const match = pathname.match(route.regex);
if (match) {
const params = {};
route.paramNames.forEach((name, i) => {
const value = match[i + 1];
if (route.pattern.includes(`[...${name}]`)) {
params[name] = value ? value.split('/') : [];
} else {
params[name] = value;
}
});
return { route, params };
}
}
return null;
}
}
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
Building a File-Based Router Generator
import { glob } from 'glob';
import path from 'path';
import { parse } from '@babel/parser';
async function generateRouteManifest(config) {
const { pagesDir, extensions = ['.tsx', '.jsx', '.ts', '.js'] } = config;
const pattern = `**/*{${extensions.join(',')}}`;
const files = await glob(pattern, { cwd: pagesDir });
const routes = [];
for (const file of files) {
if (file.startsWith('_') || file.includes('/_')) continue;
const route = await processRouteFile(pagesDir, file);
if (route) routes.push(route);
}
routes.sort((a, b) => b.score - a.score);
return routes;
}
async function processRouteFile(pagesDir, file) {
const fullPath = path.join(pagesDir, file);
const content = await fs.readFile(fullPath, 'utf-8');
const ast = parse(content, { sourceType: 'module', plugins: ['jsx', 'typescript'] });
const meta = extractRouteMeta(ast);
let route = file
.replace(/\.(tsx?|jsx?)$/, '')
.replace(/\/index$/, '')
.replace(/\[\.\.\.(\w+)\]/g, '*')
.replace(/\[\[(\w+)\]\]/g, ':$1?')
.replace(/\[(\w+)\]/g, ':$1');
if (!route) route = '/';
else if (!route.startsWith('/')) route = '/' + route;
return {
path: route,
file: fullPath,
...meta,
score: calculateScore(route),
};
}
function extractRouteMeta(ast) {
const meta = {};
for (const node of ast.program.body) {
if (node.type === 'ExportNamedDeclaration') {
if (node.declaration?.declarations?.[0]?.id?.name === 'config') {
meta.config = evaluateStaticObject(node.declaration.declarations[0].init);
}
}
}
return meta;
}
function emitRouteManifest(routes) {
return `
// Auto-generated route manifest
export const routes = [
${routes.map(r => ` {
path: ${JSON.stringify(r.path)},
component: () => import(${JSON.stringify(r.file)}),
meta: ${JSON.stringify(r.meta || {})},
}`).join(',\n')}
];
`;
}
Implementing Nested Routes and Layouts
class NestedRouter {
constructor() {
this.root = { children: [], layout: null, page: null };
}
buildTree(routes) {
for (const route of routes) {
this.insertRoute(route);
}
}
insertRoute(route) {
const segments = route.path.split('/').filter(Boolean);
let node = this.root;
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
let child = node.children.find(c => c.segment === segment);
if (!child) {
child = { segment, children: [], layout: null, page: null };
node.children.push(child);
}
node = child;
}
if (route.file.includes('layout')) {
node.layout = route.component;
} else if (route.file.includes('page')) {
node.page = route.component;
}
}
match(pathname) {
const segments = pathname.split('/').filter(Boolean);
const matched = [];
let node = this.root;
const params = {};
if (node.layout) matched.push({ component: node.layout, params: {} });
for (const segment of segments) {
let child = node.children.find(c => c.segment === segment);
if (!child) {
child = node.children.find(c => c.segment.startsWith(':'));
if (child) {
const paramName = child.segment.slice(1);
params[paramName] = segment;
}
}
if (!child) return null;
if (child.layout) {
matched.push({ component: child.layout, params: { ...params } });
}
node = child;
}
if (node.page) {
matched.push({ component: node.page, params: { ...params }, isPage: true });
}
return { matched, params };
}
}
async function renderNestedRoute(matched) {
let content = null;
for (let i = matched.length - 1; i >= 0; i--) {
const { component, params, isPage } = matched[i];
const Component = await component();
if (isPage) {
content = createElement(Component.default, { params });
} else {
content = createElement(Component.default, {
params,
children: content,
});
}
}
return content;
}
Route Preloading System
class RoutePreloader {
constructor(router) {
this.router = router;
this.preloaded = new Set();
this.preloading = new Map();
}
setupHoverPreload() {
document.addEventListener('mouseenter', (e) => {
const link = e.target.closest('a[href]');
if (!link) return;
const href = link.getAttribute('href');
if (href.startsWith('/') && !this.preloaded.has(href)) {
this.preload(href);
}
}, true);
}
setupViewportPreload() {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const href = entry.target.getAttribute('href');
if (!this.preloaded.has(href)) {
requestIdleCallback(() => this.preload(href));
}
}
});
}, { rootMargin: '200px' });
document.querySelectorAll('a[href^="/"]').forEach(link => {
observer.observe(link);
});
}
async preload(pathname) {
if (this.preloading.has(pathname)) {
return this.preloading.get(pathname);
}
const match = this.router.match(pathname);
if (!match) return;
const preloadPromise = (async () => {
const componentPromises = match.matched.map(m => m.component());
const route = match.matched.find(m => m.isPage);
const dataPromise = route?.loader?.({
params: match.params,
preload: true
});
await Promise.all([...componentPromises, dataPromise]);
this.preloaded.add(pathname);
})();
this.preloading.set(pathname, preloadPromise);
try {
await preloadPromise;
} finally {
this.preloading.delete(pathname);
}
}
}
Navigation State Management
class NavigationManager {
constructor() {
this.state = 'idle';
this.location = window.location.pathname;
this.listeners = new Set();
}
subscribe(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
notify() {
this.listeners.forEach(l => l({
state: this.state,
location: this.location,
}));
}
async navigate(to, options = {}) {
const { replace = false, state = {} } = options;
this.state = 'loading';
this.notify();
try {
for (const hook of this.beforeHooks) {
const result = await hook(this.location, to);
if (result === false) {
this.state = 'idle';
this.notify();
return;
}
if (typeof result === 'string') {
to = result;
}
}
if (replace) {
history.replaceState(state, '', to);
} else {
history.pushState(state, '', to);
}
this.location = to;
await this.renderRoute(to);
if (to.includes('#')) {
document.querySelector(to.split('#')[1])?.scrollIntoView();
} else {
window.scrollTo(0, 0);
}
} catch (error) {
console.error('Navigation failed:', error);
throw error;
} finally {
this.state = 'idle';
this.notify();
}
}
}
function useNavigation() {
const [state, setState] = useState({ state: 'idle', location: '' });
useEffect(() => {
return navigationManager.subscribe(setState);
}, []);
return state;
}
function LoadingBar() {
const { state } = useNavigation();
return state === 'loading' ? <ProgressBar /> : null;
}
Related Skills