Understanding Routing in Next.js: A Comprehensive Guide

Next.js has revolutionized the way developers build React applications by providing powerful routing capabilities that are both flexible and intuitive. In this article, we'll explore the routing system in Next.js, with a special focus on dynamic routes and advanced routing concepts.

The Fundamentals of Next.js Routing

Next.js uses a file-system based router where:

  • Files and folders in the app directory (App Router) or pages directory (Pages Router) automatically become routes
  • Routes are rendered as UI components
  • Nested routes follow the hierarchy of the file system

App Router vs Pages Router

Next.js 13 introduced the App Router, which coexists with the older Pages Router. Let's examine both:

App Router (app/ directory)

Plain Text
1app/
2ā”œā”€ā”€ layout.js
3ā”œā”€ā”€ page.js
4ā”œā”€ā”€ about/
5│   └── page.js
6└── blog/
7    ā”œā”€ā”€ layout.js
8    └── page.js
  • Uses React Server Components by default
  • Supports layouts, nested layouts, and templates
  • Provides more advanced routing patterns

Pages Router (pages/ directory)

Plain Text
1pages/
2ā”œā”€ā”€ index.js
3ā”œā”€ā”€ about.js
4└── blog/
5    └── index.js
  • The original routing system in Next.js
  • Uses React Client Components by default
  • Simpler structure but less powerful for complex applications

Dynamic Routes in Next.js

Dynamic routes allow you to create pages that can handle variable parameters. This is perfect for content-driven websites, blogs, e-commerce product pages, or any scenario where you need to generate pages based on data.

Creating Dynamic Routes

In the App Router

To create a dynamic route in the App Router, you use square brackets [param] in your folder structure:

Plain Text
1app/
2ā”œā”€ā”€ page.js
3└── blog/
4    ā”œā”€ā”€ page.js
5    └── [slug]/
6        └── page.js

In app/blog/[slug]/page.js:

React JSX
1export default function BlogPost({ params }) {
2  return <div>Blog Post: {params.slug}</div>;
3}

In the Pages Router

Similarly, in the Pages Router:

Plain Text
1pages/
2ā”œā”€ā”€ index.js
3ā”œā”€ā”€ blog.js
4└── blog/
5    └── [slug].js

In pages/blog/[slug].js:

React JSX
1export default function BlogPost({ query }) {
2  const { slug } = query;
3  return <div>Blog Post: {slug}</div>;
4}

Accessing Route Parameters

App Router

The params object is automatically passed to page components:

React JSX
1export default function ProductPage({ params }) {
2  // params.productId contains the dynamic value
3  return <div>Product: {params.productId}</div>;
4}

Pages Router

The query object from useRouter() contains the route parameters:

React JSX
1import { useRouter } from 'next/router';
2
3export default function ProductPage() {
4  const router = useRouter();
5  const { productId } = router.query;
6  
7  return <div>Product: {productId}</div>;
8}

Nested Dynamic Routes

For more complex scenarios, you might need nested dynamic routes.

In the App Router

Plain Text
1app/
2└── products/
3    └── [category]/
4        └── [productId]/
5            └── page.js

In app/products/[category]/[productId]/page.js:

React JSX
1export default function ProductPage({ params }) {
2  // Access both dynamic segments
3  return (
4    <div>
5      <h1>Category: {params.category}</h1>
6      <h2>Product ID: {params.productId}</h2>
7    </div>
8  );
9}

In the Pages Router

Plain Text
1pages/
2└── products/
3    └── [category]/
4        └── [productId].js

In pages/products/[category]/[productId].js:

React JSX
1import { useRouter } from 'next/router';
2
3export default function ProductPage() {
4  const router = useRouter();
5  const { category, productId } = router.query;
6  
7  return (
8    <div>
9      <h1>Category: {category}</h1>
10      <h2>Product ID: {productId}</h2>
11    </div>
12  );
13}

Catch-all and Optional Catch-all Routes

When you need to capture an arbitrary number of route segments, Next.js provides catch-all routes.

Catch-all Routes

App Router

Plain Text
1app/
2└── blog/
3    └── [...slug]/
4        └── page.js

In app/blog/[...slug]/page.js:

React JSX
1export default function BlogPost({ params }) {
2  // params.slug is an array of path segments
3  // e.g., for /blog/2023/01/post-title, params.slug = ['2023', '01', 'post-title']
4  return (
5    <div>
6      <h1>Blog Post</h1>
7      <p>Path segments: {params.slug.join('/')}</p>
8    </div>
9  );
10}

Pages Router

Plain Text
1pages/
2└── blog/
3    └── [...slug].js

Optional Catch-all Routes

Optional catch-all routes match even when the path segments don't exist.

App Router

Plain Text
1app/
2└── docs/
3    └── [[...slug]]/
4        └── page.js

Pages Router

Plain Text
1pages/
2└── docs/
3    └── [[...slug]].js

This matches /docs, /docs/feature, /docs/feature/concept, etc.

Data Fetching for Dynamic Routes

One of the most common use cases for dynamic routes is fetching data for each route parameter.

In the App Router

React JSX
1// app/products/[id]/page.js
2async function getProduct(id) {
3  const res = await fetch(`https://api.example.com/products/${id}`);
4  if (!res.ok) return null;
5  return res.json();
6}
7
8export default async function ProductPage({ params }) {
9  const product = await getProduct(params.id);
10  
11  if (!product) {
12    return <div>Product not found</div>;
13  }
14  
15  return (
16    <div>
17      <h1>{product.name}</h1>
18      <p>{product.description}</p>
19      <p>Price: ${product.price}</p>
20    </div>
21  );
22}

In the Pages Router

React JSX
1// pages/products/[id].js
2import { useRouter } from 'next/router';
3import { useEffect, useState } from 'react';
4
5export default function ProductPage() {
6  const router = useRouter();
7  const { id } = router.query;
8  const [product, setProduct] = useState(null);
9  const [loading, setLoading] = useState(true);
10  
11  useEffect(() => {
12    if (!id) return; // Router not ready yet
13    
14    async function fetchProduct() {
15      try {
16        const res = await fetch(`https://api.example.com/products/${id}`);
17        if (!res.ok) throw new Error('Failed to fetch');
18        const data = await res.json();
19        setProduct(data);
20      } catch (error) {
21        console.error('Error fetching product:', error);
22      } finally {
23        setLoading(false);
24      }
25    }
26    
27    fetchProduct();
28  }, [id]);
29  
30  if (loading) return <div>Loading...</div>;
31  if (!product) return <div>Product not found</div>;
32  
33  return (
34    <div>
35      <h1>{product.name}</h1>
36      <p>{product.description}</p>
37      <p>Price: ${product.price}</p>
38    </div>
39  );
40}

Generating Static Pages for Dynamic Routes

Next.js allows you to pre-render pages at build time, even for dynamic routes.

In the App Router

React JSX
1// app/posts/[slug]/page.js
2export async function generateStaticParams() {
3  const posts = await fetchPosts();
4  
5  return posts.map((post) => ({
6    slug: post.slug,
7  }));
8}
9
10async function fetchPost(slug) {
11  const res = await fetch(`https://api.example.com/posts/${slug}`);
12  if (!res.ok) return null;
13  return res.json();
14}
15
16export default async function Post({ params }) {
17  const post = await fetchPost(params.slug);
18  
19  return (
20    <article>
21      <h1>{post.title}</h1>
22      <div dangerouslySetInnerHTML={{ __html: post.content }} />
23    </article>
24  );
25}

In the Pages Router

React JSX
1// pages/posts/[slug].js
2export async function getStaticPaths() {
3  const posts = await fetchPosts();
4  
5  return {
6    paths: posts.map((post) => ({
7      params: { slug: post.slug },
8    })),
9    fallback: 'blocking', // or false or true
10  };
11}
12
13export async function getStaticProps({ params }) {
14  const post = await fetchPost(params.slug);
15  
16  if (!post) {
17    return { notFound: true };
18  }
19  
20  return {
21    props: { post },
22    revalidate: 60, // Regenerate page every 60 seconds if requested
23  };
24}
25
26export default function Post({ post }) {
27  return (
28    <article>
29      <h1>{post.title}</h1>
30      <div dangerouslySetInnerHTML={{ __html: post.content }} />
31    </article>
32  );
33}

Parallel Routes and Intercepted Routes (App Router Only)

Next.js 13 introduced even more powerful routing capabilities.

Parallel Routes

Parallel routes allow you to simultaneously render multiple pages in the same layout.

Plain Text
1app/
2ā”œā”€ā”€ layout.js
3ā”œā”€ā”€ @dashboard/
4│   └── page.js
5└── @analytics/
6    └── page.js

In app/layout.js:

React JSX
1export default function Layout({ children, dashboard, analytics }) {
2  return (
3    <div>
4      <div className="main">{children}</div>
5      <div className="dashboard">{dashboard}</div>
6      <div className="analytics">{analytics}</div>
7    </div>
8  );
9}

Intercepted Routes

Intercepted routes let you "intercept" a route and show different content while preserving the URL.

Plain Text
1app/
2ā”œā”€ā”€ feed/
3│   └── page.js
4└── photo/
5    ā”œā”€ā”€ [id]/
6    │   └── page.js
7    └── (.)[id]/
8        └── page.js  // This intercepts /photo/[id]

This is particularly useful for modals and specialized views.

Route Handlers (API Routes)

Next.js provides a way to create API endpoints as part of your application.

In the App Router

JavaScript
1// app/api/products/route.js
2export async function GET() {
3  const products = await fetchProducts();
4  return Response.json(products);
5}
6
7export async function POST(request) {
8  const data = await request.json();
9  const newProduct = await createProduct(data);
10  return Response.json(newProduct, { status: 201 });
11}

In the Pages Router

JavaScript
1// pages/api/products.js
2export default async function handler(req, res) {
3  if (req.method === 'GET') {
4    const products = await fetchProducts();
5    res.status(200).json(products);
6  } else if (req.method === 'POST') {
7    const newProduct = await createProduct(req.body);
8    res.status(201).json(newProduct);
9  } else {
10    res.setHeader('Allow', ['GET', 'POST']);
11    res.status(405).end(`Method ${req.method} Not Allowed`);
12  }
13}

Middleware for Route Processing

Next.js middleware allows you to run code before a request is completed, perfect for authentication, redirects, or rewriting URLs.

JavaScript
1// middleware.js (at the root of your project)
2import { NextResponse } from 'next/server';
3
4export function middleware(request) {
5  // Check if user is authenticated
6  const isAuthenticated = checkAuth(request);
7  
8  if (!isAuthenticated && request.nextUrl.pathname.startsWith('/dashboard')) {
9    return NextResponse.redirect(new URL('/login', request.url));
10  }
11  
12  // Add headers
13  const response = NextResponse.next();
14  response.headers.set('x-custom-header', 'custom-value');
15  return response;
16}
17
18export const config = {
19  matcher: ['/dashboard/:path*', '/api/:path*'],
20};

URL Handling and Query Parameters

Next.js provides utilities for working with URLs and query parameters.

In the App Router

React JSX
1// app/search/page.js
2export default function SearchPage({ searchParams }) {
3  const query = searchParams.query || '';
4  const page = searchParams.page ? parseInt(searchParams.page) : 1;
5  
6  return (
7    <div>
8      <h1>Search Results for: {query}</h1>
9      <p>Page: {page}</p>
10      {/* Search results */}
11    </div>
12  );
13}

In the Pages Router

React JSX
1// pages/search.js
2import { useRouter } from 'next/router';
3
4export default function SearchPage() {
5  const router = useRouter();
6  const { query = '', page = '1' } = router.query;
7  
8  return (
9    <div>
10      <h1>Search Results for: {query}</h1>
11      <p>Page: {parseInt(page)}</p>
12      {/* Search results */}
13    </div>
14  );
15}

Next.js provides client-side navigation between routes without full page refreshes.

React JSX
1import Link from 'next/link';
2
3export default function Navigation() {
4  return (
5    <nav>
6      <Link href="/">Home</Link>
7      <Link href="/about">About</Link>
8      <Link href={`/blog/${postSlug}`}>Read Post</Link>
9      <Link href="/products?category=electronics">Electronics</Link>
10    </nav>
11  );
12}

Programmatic Navigation

React JSX
1// App Router
2'use client';
3
4import { useRouter } from 'next/navigation';
5
6export default function LoginForm() {
7  const router = useRouter();
8  
9  const handleSubmit = async (e) => {
10    e.preventDefault();
11    const success = await login(/* credentials */);
12    
13    if (success) {
14      router.push('/dashboard');
15    }
16  };
17  
18  return (/* form */);
19}
20
21// Pages Router
22import { useRouter } from 'next/router';
23
24export default function LoginForm() {
25  const router = useRouter();
26  
27  const handleSubmit = async (e) => {
28    e.preventDefault();
29    const success = await login(/* credentials */);
30    
31    if (success) {
32      router.push('/dashboard');
33    }
34  };
35  
36  return (/* form */);
37}

Error Handling in Routes

Next.js provides built-in error handling for routes.

In the App Router

React JSX
1// app/products/[id]/error.js
2'use client';
3
4export default function Error({ error, reset }) {
5  return (
6    <div>
7      <h2>Something went wrong loading this product!</h2>
8      <p>{error.message}</p>
9      <button onClick={() => reset()}>Try again</button>
10    </div>
11  );
12}

In the Pages Router

React JSX
1// pages/_error.js
2function Error({ statusCode }) {
3  return (
4    <p>
5      {statusCode
6        ? `An error ${statusCode} occurred on server`
7        : 'An error occurred on client'}
8    </p>
9  );
10}
11
12Error.getInitialProps = ({ res, err }) => {
13  const statusCode = res ? res.statusCode : err ? err.statusCode : 404;
14  return { statusCode };
15};
16
17export default Error;

Loading States

Next.js also provides built-in loading states for routes.

In the App Router

React JSX
1// app/products/loading.js
2export default function Loading() {
3  return <div>Loading products...</div>;
4}

Best Practices for Next.js Routing

  1. Organize Routes Logically: Structure your routes to reflect the information hierarchy of your application.

  2. Use Layouts Effectively: Leverage layouts to avoid code duplication across related routes.

  3. Optimize Data Fetching: Fetch data at the appropriate level and cache it when possible.

  4. Handle Loading and Error States: Always provide good user experiences during loading and error situations.

  5. Use Static Generation When Possible: Pre-render pages at build time when the content doesn't change frequently.

  6. Implement Proper SEO: Use metadata, proper titles, and descriptions for all routes.

  7. Secure Routes Appropriately: Use middleware for authentication and authorization checks.

  8. Test Your Routes: Ensure your routing works correctly across different scenarios and edge cases.

Conclusion

Next.js provides a powerful and flexible routing system that can handle everything from simple static pages to complex dynamic routes. By understanding the various routing features and patterns available in Next.js, you can build well-structured, performant, and user-friendly web applications.

Whether you're using the newer App Router or the classic Pages Router, Next.js offers tools to create intuitive navigation, handle data fetching, manage loading and error states, and optimize performance. As your application grows in complexity, these routing capabilities become even more valuable for maintaining a clean architecture and excellent user experience.

Last updated: 6/8/2025