Client-Side Routing In Next.js

About The Author

Adebiyi Adedotun Lukman is a UI/Frontend Engineer based in Lagos, Nigeria who also happens to love UI/UX Design for the love of great software products. When … More about Adebiyi ↬

Email Newsletter

Weekly tips on front-end & UX.
Trusted by 200,000+ folks.

Next.js has a file-based routing system in which each page automatically becomes a route based on its file name. Each page is a default exported React component from the pages directory that can be used to define the most common route patterns. This article will guide you through almost everything you need to know about Routing in Next.js and point you in the direction of related topics and concepts.

Hyperlinks have been one of the jewels of the Web since its inception . According to MDN, hyperlinks are what makes the Web, a web. While used for purposes such as linking between documents, its primary use is to reference different web pages identifiable by a unique web address or a URL.

Routing is an important aspect of each web application as much as hyperlinks are to the Web. It is a mechanism through which requests are routed to the code that handles them. In relation to routing, Next.js pages are referenced and identifiable by a unique URL path. If the Web consists of navigational web pages interconnected by hyperlinks, then each Next.js app consists of route-able pages (route handlers or routes) interconnected by a router.

Next.js has built-in support for routing that can be unwieldy to unpack, especially when considering rendering and data fetching. As a prerequisite to understanding client-side routing in Next.js, it is necessary to have an overview of concepts like routing, rendering, and data fetching in Next.js.

This article will be beneficial to React developers who are familiar with Next.js and want to learn how it handles routing. You need to have a working knowledge of React and Next.js to get the most out of the article, which is solely about client-side routing and related concepts in Next.js.

Routing And Rendering

Routing and Rendering are complementary to each other and will play a huge part through the course of this article. I like how Gaurav explains them:

Routing is the process through which the user is navigated to different pages on a website.

Rendering is the process of putting those pages on the UI. Every time you request a route to a particular page, you are also rendering that page, but not every render is an outcome of a route.

Take five minutes to think about that.

What you need to understand about rendering in Next.js is that each page is pre-rendered in advance alongside the minimal JavaScript code necessary for it to become fully interactive through a process known as hydration. How Next.js does this is highly dependent on the form of pre-rendering: Static Generation or Server-side rendering, which are both highly coupled to the data fetching technique used, and separated by when the HTML for a page is generated.

Depending on your data fetching requirements, you might find yourself using built-in data fetching functions like getStaticProps, getStaticPaths, or, getServerSideProps, client-side data fetching tools like SWR, react-query, or traditional data fetching approaches like fetch-on-render, fetch-then-render, render-as-you-fetch (with Suspense).

Pre-rendering (before rendering — to the UI) is complementary to Routing, and highly coupled with data fetching — a whole topic of its own in Next.js. So while these concepts are either complementary or closely related, this article will be solely focused on mere navigation between pages (routing), with references to related concepts where necessary.

With that out of the way, let’s begin with the fundamental gist: Next.js has a file-system-based router built on the concept of pages.

Pages

Pages in Next.js are React Components that are automatically available as routes. They are exported as default exports from the pages directory with supported file extensions like .js, .jsx, .ts, or .tsx.

A typical Next.js app will have a folder structure with top-level directories like pages, public, and styles.

next-app
├── node_modules
├── pages
│   ├── index.js // path: base-url (/)
│   ├── books.jsx // path: /books
│   └── book.ts // path: /book
├── public
├── styles
├── .gitignore
├── package.json
└── README.md

Each page is a React component:

// pages/books.js — `base-url/book`
export default function Book() {
  return 

Books

}

Note: Keep in mind that pages can also be referred to as “route handlers”.

Custom Pages

These are special pages that reside in the pages directory but do not participate in routing. They are prefixed with the underscore symbol, as in, _app.js, and _document.js.

  • _app.js
    This is a custom component that resides in the pages folder. Next.js uses this component to initialize pages.
  • _document.js
    Like _app.js, _document.js is a custom component that Next.js uses to augment your applications <html> and <body> tags. This is necessary because Next.js pages skip the definition of the surrounding document’s markup.
next-app
├── node_modules
├── pages
│   ├── _app.js // ⚠️ Custom page (unavailable as a route)
│   ├── _document.jsx // ⚠️ Custom page (unavailable as a route)
│   └── index.ts // path: base-url (/)
├── public
├── styles
├── .gitignore
├── package.json
└── README.md

Linking Between Pages

Next.js exposes a Link component from the next/link API that can be used to perform client-side route transitions between pages.

// Import the <Link/> component
import Link from "next/link";

// This could be a page component
export default function TopNav() {
  return (
    <nav>
      <Link href="/">Home</Link>
      <Link href="/">Publications</Link>
      <Link href="/">About</Link>
    </nav>
  )
}

// This could be a non-page component
export default function Publications() {
  return (
    <section>
      <TopNav/>
      {/* ... */}
    </section>
  )
}

The Link component can be used inside any component, page or not. When used in its most basic form as in the example above, the Link component translates to a hyperlink with an href attribute. (More on Link in the next/link section below.)

Routing

Next.js file-based routing system can be used to define the most common route patterns. To accommodate for these patterns, each route is separated based on its definition.

Index Routes

By default, in your Next.js app, the initial/default route is pages/index.js which automatically serves as the starting point of your application as /. With a base URL of localhost:3000, this index route can be accessed at the base URL level of the application in the browser.

Index routes automatically act as the default route for each directory and can eliminate naming redundancies. The directory structure below exposes two route paths: / and /home.

next-app
└── pages
    ├── index.js // path: base-url (/)
    └── home.js // path: /home

The elimination is more apparent with nested routes.

Nested Routes

A route like pages/book is one level deep. To go deeper is to create nested routes, which requires a nested folder structure. With a base-url of https://www.smashingmagazine.com, you can access the route https://www.smashingmagazine.com/printed-books/printed-books by creating a folder structure similar to the one below:

next-app
└── pages
    ├── index.js // top index route
    └── printed-books // nested route
        └── printed-books.js // path: /printed-books/printed-books

Or eliminate path redundancy with index routes and access the route for printed books at https://www.smashingmagazine.com/printed-books.

next-app
└── pages
    ├── index.js // top index route
    └── printed-books // nested route
        └── index.js // path: /printed-books

Dynamic routes also play an important role in eliminating redundancies.

Dynamic Routes

From the previous example we use the index route to access all printed books. To access individual books requires either creating different routes for each book like:

// ⚠️ Don't do this.
next-app
└── pages
    ├── index.js // top index route
    └── printed-books // nested route
        ├── index.js // path: /printed-books
        ├── typesript-in-50-lessons.js // path: /printed-books/typesript-in-50-lessons
        ├── checklist-cards.js // path: /printed-books/checklist-cards
        ├── ethical-design-handbook.js // path: /printed-books/ethical-design-handbook
        ├── inclusive-components.js // path: /printed-books/inclusive-components
        └── click.js // path: /printed-books/click

which is highly redundant, unscalable, and can be remedied with dynamic routes like:

// ✅ Do this instead.
next-app
└── pages
    ├── index.js // top index route
    └── printed-books
        ├── index.js // path: /printed-books
        └── [book-id].js // path: /printed-books/:book-id

The bracket syntax — [book-id] — is the dynamic segment, and is not limited to files alone. It can also be used with folders like the example below, making the author available at the route /printed-books/:book-id/author.

next-app
└── pages
    ├── index.js // top index route
    └── printed-books
        ├── index.js // path: /printed-books
        └── [book-id]
            └── author.js // path: /printed-books/:book-id/author

The dynamic segment(s) of a route is exposed as a query parameter that can be accessed in any of the connecting component involved in the route with query object of the useRouter() hook — (More on this in the next/router API section).

// printed-books/:book-id
import { useRouter } from 'next/router';

export default function Book() {
  const { query } = useRouter();

  return (
    <div>
      <h1>
        book-id <em>{query['book-id']}</em>
      </h1>
    </div>
  );
}
// /printed-books/:book-id/author
import { useRouter } from 'next/router';

export default function Author() {
  const { query } = useRouter();

  return (
    <div>
      <h1>
        Fetch author with book-id <em>{query['book-id']}</em>
      </h1>
    </div>
  );
}

Extending Dynamic Route Segments With Catch All Routes

You’ve seen the dynamic route segment bracket syntax as in the previous example with [book-id].js. The beauty of this syntax is that it takes things even further with Catch-All Routes. You can infer what this does from the name: it catches all routes.

When we looked at the dynamic example, we learned how it helps eliminate file creation redundancy for a single route to access multiple books with their ID. But there’s something else we could have done.

Specifically, we had the path /printed-books/:book-id, with a directory structure:

next-app
└── pages
    ├── index.js
    └── printed-books
        ├── index.js
        └── [book-id].js

If we updated the path to have more segments like categories, we might end up with something like: /printed-books/design/:book-id, /printed-books/engineering/:book-id, or better still /printed-books/:category/:book-id.

Let’s add the release year: /printed-books/:category/:release-year/:book-id. Can you see a pattern? The directory structure becomes:

next-app
└── pages
    ├── index.js
    └── printed-books
        └── [category]
            └── [release-year]
                └── [book-id].js

We substituted the use of named files for dynamic routes, but somehow still ended up with another form of redundancy. Well, there’s a fix: Catch All Routes that eliminates the need for deeply nested routes:

next-app
└── pages
    ├── index.js
    └── printed-books
        └── [...slug].js

It uses the same bracket syntax except that it is prefixed with three dots. Think of the dots like the JavaScript spread syntax. You might be wondering: If I use the catch-all routes, how do I access the category ([category]), and release year ([release-year]). Two ways:

  1. In the case of the printed-books example, the end goal is the book, and each book info will have its metadata attached with it, or
  2. The “slug” segments are returned as an array of query parameter(s).
import { useRouter } from 'next/router';

export default function Book() {
  const { query } = useRouter();
  // There's a brief moment where `slug` is undefined
  // so we use the Optional Chaining (?.) and Nullish coalescing operator (??)
  // to check if slug is undefined, then fall back to an empty array
  const [category, releaseYear, bookId] = query?.slug ?? [];

  return (
    <table>
      <tbody>
        <tr>
          <th>Book Id</th>
          <td>{bookId}</td>
        </tr>
        <tr>
          <th>Category</th>
          <td>{category}</td>
        </tr>
        <tr>
          <th>Release Year</th>
          <td>{releaseYear}</td>
        </tr>
      </tbody>
    </table>
  );
}

Here’s more example for the route /printed-books/[…slug]:

PathQuery parameter
/printed-books/click.js{ “slug”: [“click”] }
/printed-books/2020/click.js{ “slug”: [“2020”, “click”] }
/printed-books/design/2020/click.js{ “slug”: [“design”, “2020”, “click”] }

As it is with the catch-all route, the route /printed-books will throw a 404 error unless you provide a fallback index route.

next-app
└── pages
    ├── index.js
    └── printed-books
        ├── index.js // path: /printed-books
        └── [...slug].js

This is because the catch-all route is “strict”. It either matches a slug, or it throws an error. If you’d like to avoid creating index routes alongside catch-all routes, you can use the optional catch-all routes instead.

Extending Dynamic Route Segments With Optional Catch-All Routes

The syntax is the same as catch-all-routes, but with double square brackets instead.

next-app
└── pages
    ├── index.js
    └── printed-books
        └── [[...slug]].js

In this case, the catch-all route (slug) is optional and if not available, fallbacks to the path /printed-books, rendered with [[…slug]].js route handler, without any query params.

Use catch-all alongside index routes, or optional catch-all routes alone. Avoid using catch-all and optional catch-all routes alongside.

Routes Precedence

The capability to be able to define the most common routing patterns can be a “black swan”. The possibility of routes clashing is a looming threat, most especially when you start getting dynamic routes worked up.

When it makes sense to do so, Next.js lets you know about route clashes in the form of errors. When it doesn’t, it applies precedence to routes according to their specificity.

For example, it is an error to have more than one dynamic route on the same level.

// ❌ This is an error
// Failed to reload dynamic routes: Error: You cannot use different slug names for the // same dynamic path ('book-id' !== 'id').
next-app
└── pages
    ├── index.js
    └── printed-books
        ├── [book-id].js
        └── [id].js

If you look closely at the routes defined below, you’d notice the potential for clashes.

// Directory structure flattened for simplicity
next-app
└── pages
    ├── index.js // index route (also a predefined route)
    └── printed-books
        ├── index.js
        ├── tags.js // predefined route
        ├── [book-id].js // handles dynamic route
        └── [...slug].js // handles catch all route

For example, try answering this: what route handles the path /printed-books/inclusive-components?

  • /printed-books/[book-id].js, or
  • /printed-books/[…slug].js.

The answer lies in the “specificity” of the route handlers. Predefined routes come first, followed by dynamic routes, then catch-all routes. You can think of the route request/handling model as a pseudo-code with the following steps:

  1. Is there is a predefined route handler that can handle the route?
    • true — handle the route request.
    • false — go to 2.
  2. Is there a dynamic route handler that can handle the route?
    • true — handle the route request.
    • false — go to 3.
  3. Is there a catch-all route handler that can handle the route?
    • true — handle the route request.
    • false — throw a 404 page not found.

Therefore, /printed-books/[book-id].js wins.

Here are more examples:

RouteRoute handlerType of route
/printed-books/printed-booksIndex route
/printed-books/tags/printed-books/tags.jsPredefined route
/printed-books/inclusive-components/printed-books/[book-id].jsDynamic route
/printed-books/design/inclusive-components/printed-books/[...slug].jsCatch-all route

The next/link API exposes the Link component as a declarative way to perform client-side route transitions.

import Link from 'next/link'

function TopNav() {
  return (
    <nav>
      <Link href="/">Smashing Magazine</Link>
      <Link href="/articles">Articles</Link>
      <Link href="/guides">Guides</Link>
      <Link href="/printed-books">Books</Link>
    </nav>
  )
}

The Link component will resolve to a regular HTML hyperlink. That is, <Link href="/">Smashing Magazine</Link> will resolve to <a href="/">Smashing Magazine</a>.

The href prop is the only required prop to the Link component. See the docs for a complete list of props available on the Link component.

There are other mechanisms of the Link component to be aware of.

Routes With Dynamic Segments

Prior to Next.js 9.5.3, Linking to dynamic routes meant that you had to provide both the href and as prop to Link as in:

import Link from 'next/link';

const printedBooks = [
  { name: 'Ethical Design', id: 'ethical-design' },
  { name: 'Design Systems', id: 'design-systems' },
];

export default function PrintedBooks() {
  return printedBooks.map((printedBook) => (
    <Link
      href="/printed-books/[printed-book-id]"
      as={`/printed-books/${printedBook.id}`}
    >
      {printedBook.name}
    </Link>
  ));
}

Although this allowed Next.js to interpolate the href for the dynamic parameters, it was tedious, error-prone, and somewhat imperative, and has now been fixed for the majority of use-cases with the release of Next.js 10.

This fix is also backward compatible. If you have been using both as and href, nothing breaks. To adopt the new syntax, discard the href prop and its value, and rename the as prop to href as in the example below:

import Link from 'next/link';

const printedBooks = [
  { name: 'Ethical Design', id: 'ethical-design' },
  { name: 'Design Systems', id: 'design-systems' },
];

export default function PrintedBooks() {
  return printedBooks.map((printedBook) => (
    <Link href={`/printed-books/${printedBook.id}`}>{printedBook.name}</Link>
  ));
}

See Automatic resolving of href.

Use-cases For The passHref Prop

Take a close look at the snippet below:

import Link from 'next/link';

const printedBooks = [
  { name: 'Ethical Design', id: 'ethical-design' },
  { name: 'Design Systems', id: 'design-systems' },
];

// Say this has some sort of base styling attached
function CustomLink({ href, name }) {
  return <a href={href}>{name}</a>;
}

export default function PrintedBooks() {
  return printedBooks.map((printedBook) => (
    <Link href={`/printed-books/${printedBook.id}`} passHref>
      <CustomLink name={printedBook.name} />
    </Link>
  ));
}

The passHref props force the Link component to pass the href prop down to the CustomLink child component. This is compulsory if the Link component wraps over a component that returns a hyperlink <a> tag. Your use-case might be because you are using a library like styled-components, or if you need to pass multiple children to the Link component, as it only expects a single child.

See the docs to learn more.

URL Objects

The href prop of the Link component can also be a URL object with properties like query which is automatically formatted into a URL string.

With the printedBooks object, the example below will link to:

  1. /printed-books/ethical-design?name=Ethical+Design and
  2. /printed-books/design-systems?name=Design+Systems.
import Link from 'next/link';

const printedBooks = [
  { name: 'Ethical Design', id: 'ethical-design' },
  { name: 'Design Systems', id: 'design-systems' },
];

export default function PrintedBooks() {
  return printedBooks.map((printedBook) => (
    <Link
      href={{
        pathname: `/printed-books/${printedBook.id}`,
        query: { name: `${printedBook.name}` },
      }}
    >
      {printedBook.name}
    </Link>
  ));
}

If you include a dynamic segment in the pathname, then you must also include it as a property in the query object to make sure the query is interpolated in the pathname:

import Link from 'next/link';

const printedBooks = [
  { name: 'Ethical Design', id: 'ethical-design' },
  { name: 'Design Systems', id: 'design-systems' },
];

// In this case the dynamic segment `[book-id]` in pathname
// maps directly to the query param `book-id`
export default function PrintedBooks() {
  return printedBooks.map((printedBook) => (
    <Link
      href={{
        pathname: `/printed-books/[book-id]`,
        query: { 'book-id': `${printedBook.id}` },
      }}
    >
      {printedBook.name}
    </Link>
  ));
}

The example above have paths:

  1. /printed-books/ethical-design, and
  2. /printed-books/design-systems.

If you inspect the href attribute in VSCode, you’d find the type LinkProps, with the href property a Url type, which is either a string or UrlObject as mentioned previously.

A screenshot of the inspected LinkProps type in VSCode
Inspecting LinkProps in VSCode. (Large preview)

Inspecting the UrlObject further leads to the interface with the properties:

A screenshot of the inspected <code>UrlObject</code> in VSCode
Inspecting UrlObject in VSCode. (Large preview)

You can learn more about these properties in the Node.js URL module documentation.

One use case of the hash is to link to specific sections in a page.

import Link from 'next/link';

const printedBooks = [{ name: 'Ethical Design', id: 'ethical-design' }];

export default function PrintedBooks() {
  return printedBooks.map((printedBook) => (
    <Link
      href={{
        pathname: `/printed-books/${printedBook.id}`,
        hash: 'faq',
      }}
    >
      {printedBook.name}
    </Link>
  ));
}

The hyperlink will resolve to /printed-books/ethical-design#faq.

Learn more in the docs.

The next/router API

If the next/link is declarative, then the next/router is imperative. It exposes a useRouter hook that allows access to the router object inside any function component. You can use this hook to manually perform routing, most especially in certain scenarios where the next/link is not enough, or where you need to “hook” into the routing.

import { useRouter } from 'next/router';

export default function Home() {
  const router = useRouter();

  function handleClick(e) {
    e.preventDefault();
    router.push(href);
  }

  return (
    <button type="button" onClick={handleClick}>Click me</button>
  )
}

useRouter is a React hook and cannot be used with classes. Need the router object in class components? Use withRouter.

import { withRouter } from 'next/router';

function Home({router}) {
  function handleClick(e) {
    e.preventDefault();
    router.push(href);
  }

  return (
    <button type="button" onClick={handleClick}>Click me</button>
  )
}

export default withRouter(Home);

The router Object

Both the useRouter hook and withRouter higher-order component, return a router object with properties like pathname, query, asPath, and basePath that gives you information about the URL state of the current page, locale, locales, and defaultLocale that gives information about the active, supported, or current default locale.

The router object also has methods like push for navigating to a new URL by adding a new URL entry into the history stack, replace, similar to push but replaces the current URL instead of adding a new URL entry into the history stack.

Learn more about the router object.

Custom Route Configuration With next.config.js

This is a regular Node.js module that can be used to configure certain Next.js behavior.

module.exports = {
  // configuration options
}

Remember to restart your server anytime you update next.config.js. Learn more.

Base Path

It was mentioned that the initial/default route in Next.js is pages/index.js with path /. This is configurable and you can make your default route a sub-path of the domain.

module.exports = {
  // old default path: /
  // new default path: /dashboard
  basePath: '/dashboard',
};

These changes will automatically take effect in your application with all / paths routed to /dashboard.

This feature can only be used with Next.js 9.5 and above. Learn more.

Trailing Slash

By default, a trailing slash will not be available at the end of each URL. However, you can switch that with:

module.exports = {
  trailingSlash: true
};
# trailingSlash: false
/printed-books/ethical-design#faq
# trailingSlash: true
/printed-books/ethical-design/#faq

Both the base path and trailing slash features can only be used with Next.js 9.5 and above.

Conclusion

Routing is one of the most important parts of your Next.js application, and it reflects in the file-system-based router built on the concept of pages. Pages can be used to define the most common route patterns. The concepts of routing and rendering are closely related. Take the lessons of this article with you as you build your own Next.js app or work on a Next.js codebase. And check the resources below to learn more.

Smashing Editorial (vf, yk, il)