Routing is the backbone of any modern web application. Get it right, and users navigate fluidly through your content. Get it wrong, and you're fighting sluggish transitions, tangled layouts, and brittle URL structures. Next.js offers one of the most flexible routing systems in the React ecosystem — but taking full advantage of it requires understanding the patterns that go beyond a basic pages/index.js.
This article covers the techniques that matter most: dynamic routing, nested layouts, advanced navigation patterns, and the middleware layer that ties them together.
1. Dynamic Routing and Route Matching
Dynamic routing lets you create a single file that handles an entire class of URLs. A blog where each post has a unique slug is the classic example — rather than creating a separate file for every post, you define one route using square brackets.
Note on router versions: The examples below use the
pagesdirectory andnext/router. If you're using theappdirectory (Next.js 13+), replaceuseRouterfromnext/routerwithuseParamsfromnext/navigation.
Creating Dynamic Routes
// pages/blog/[slug].js
import { useRouter } from 'next/router';
const BlogPost = () => {
const { slug } = useRouter().query;
return <h1>Blog Post: {slug}</h1>;
};
export default BlogPost;
This maps any URL matching /blog/* — such as /blog/my-first-post — to the BlogPost component, passing the matched segment as slug via the query object.
Handling Multiple Dynamic Segments
For more structured content hierarchies, nest dynamic segments:
// pages/blog/[category]/[slug].js
const BlogCategoryPost = () => {
const { category, slug } = useRouter().query;
return (
<div>
<h1>Category: {category}</h1>
<h2>Post: {slug}</h2>
</div>
);
};
export default BlogCategoryPost;
This supports URLs like /blog/tech/nextjs-advanced-routing.
Catch-All Routes
When you need to match an arbitrary number of path segments — for example, a documentation site with deeply nested pages — use the spread syntax inside brackets:
// pages/docs/[...slug].js
const DocsPage = () => {
const { slug } = useRouter().query;
// slug is an array: ['guide', 'installation', 'windows']
return <h1>Docs: {slug?.join(' / ')}</h1>;
};
Use [[...slug]].js (double brackets) to also match the root path /docs.
Optimizing Route Transitions
next/link handles client-side navigation and automatically prefetches linked pages in the background, so subsequent loads feel instant:
import Link from 'next/link';
const BlogList = ({ posts }) => (
<ul>
{posts.map((post) => (
<li key={post.slug}>
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
</li>
))}
</ul>
);
For cases like filtering or pagination — where you want to update the URL without triggering a full navigation — use shallow routing:
router.push('/blog?page=2', undefined, { shallow: true });
Dynamic API Routes
Dynamic segments work identically in API routes:
// pages/api/blog/[slug].js
export default function handler(req, res) {
const { slug } = req.query;
res.status(200).json({ message: `Data for blog post: ${slug}` });
}
This endpoint is accessible at /api/blog/my-first-post and follows the same file-based convention as page routes.
2. Nested Layouts and Shared Components
The app directory, introduced in Next.js 13, makes layout composition a first-class feature. Instead of manually wrapping pages in providers and shell components, you colocate layout files with the routes they govern.
Defining Nested Layouts
Layout files wrap their subtree automatically. A root layout provides the outer shell; a section-specific layout adds structure for that section only:
// app/layout.js
export default function RootLayout({ children }) {
return (
<html>
<body>
<header>My App Header</header>
{children}
<footer>My App Footer</footer>
</body>
</html>
);
}
// app/blog/layout.js
export default function BlogLayout({ children }) {
return (
<div className="blog-container">
<aside>Blog Sidebar</aside>
<main>{children}</main>
</div>
);
}
When a user visits /blog/my-post, Next.js renders the root header and footer around the blog sidebar and the post content — with no manual wrapping required. Only the content that changes between navigations re-renders, which reduces unnecessary layout re-renders and improves performance.
Managing Shared State
For state that needs to cross layout boundaries, React context is a clean solution for small to medium applications:
// components/ThemeProvider.js
'use client';
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext();
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => useContext(ThemeContext);
Note the 'use client' directive — any component using hooks or browser APIs must opt into client-side rendering within the app directory.
As your app scales, frequent context updates can trigger re-renders across deeply nested components. When that becomes a problem, libraries like Zustand or Redux offer more granular control.
Metadata
The app directory also provides a clean API for defining page metadata without a separate <Head> component:
// app/blog/layout.js
export const metadata = {
title: 'Blog — My App',
description: 'Read the latest posts on our blog.',
};
Next.js merges metadata from nested layouts and pages automatically, with child values overriding parents.
3. Advanced Navigation Patterns
Programmatic Navigation
The useRouter hook (or useRouter from next/navigation in the app directory) enables navigation triggered by user interactions or application logic:
import { useRouter } from 'next/router';
const NavigateButton = () => {
const router = useRouter();
return (
<button onClick={() => router.push('/dashboard')}>
Go to Dashboard
</button>
);
};
Use router.replace instead of router.push when you don't want the navigation to add an entry to the browser history — useful for redirects after form submission.
Scroll Restoration
By default, Next.js scrolls to the top on each navigation. To restore a user's previous scroll position when they navigate back, enable scroll restoration in next.config.js:
module.exports = {
experimental: {
scrollRestoration: true,
},
};
This is especially valuable in content-heavy applications — news feeds, search results, long-form article lists — where losing scroll position forces users to re-find their place.
Optimized Preloading
next/link prefetches in-viewport links automatically. For routes the user is very likely to visit next but that aren't yet on screen, you can trigger prefetching programmatically:
import { useEffect } from 'react';
import Router from 'next/router';
const HomePage = () => {
useEffect(() => {
Router.prefetch('/dashboard');
}, []);
return <h1>Welcome</h1>;
};
Be selective here. Prefetching every possible route increases memory usage and can degrade overall performance. Focus on routes that appear immediately after the user's current step — the next page in an onboarding flow, for instance, or the most commonly clicked item in a navigation menu.
Middleware for Access Control
Middleware runs before a request reaches its route handler, making it the right place to enforce authentication, role-based access, or feature flags without touching individual page components:
// middleware.js
export function middleware(req) {
const token = req.cookies.get('auth-token');
if (!token) {
return Response.redirect(new URL('/login', req.url));
}
}
export const config = {
matcher: ['/dashboard/:path*'],
};
The matcher config limits the middleware to specific paths, so it doesn't run on every request. For more complex scenarios, you can inspect the token to check roles and redirect to different destinations based on permissions.
Recommended Folder Structure
As your routing logic grows, keeping the project organized becomes critical. Here's a structure that accommodates all the patterns above:
/nextjs-advanced-routing
├── /app
│ ├── layout.js # Root layout
│ └── /blog
│ ├── layout.js # Blog-specific layout
│ └── page.js
├── /pages
│ ├── /blog
│ │ ├── [slug].js # Dynamic route
│ │ └── /[category]
│ │ └── [slug].js # Nested dynamic route
│ └── /api
│ └── /blog
│ └── [slug].js # Dynamic API route
├── /components
│ ├── BlogList.js
│ └── ThemeProvider.js
├── middleware.js
├── next.config.js
└── package.json
Conclusion
Next.js routing is powerful precisely because it scales with your application. You can start with a handful of static pages and gradually introduce dynamic segments, nested layouts, and middleware as complexity demands — without restructuring what you've already built.
The patterns covered here — dynamic and catch-all routes, layout composition, scroll restoration, selective prefetching, and middleware-based access control — address the most common challenges you'll encounter as a Next.js application matures. Master these, and you'll have a routing layer that's both flexible enough to handle edge cases and structured enough to stay maintainable as your team and codebase grow.
Top comments (0)