Parallel Routes in Next.js
Parallel routes in Next.js are a powerful new way to structure your application’s layout and content, allowing you to render multiple independent pages within the same layout simultaneously. With an example, this tutorial will look at parallel routes and their benefits.
Discover how at OpenReplay.com.
Parallel routes in Next.js significantly advance the framework’s routing capabilities. They allow developers to render multiple pages within the same layout simultaneously or conditionally, which is particularly beneficial for dynamic sections of applications like dashboards and social media feeds.
Traditionally, routing in Next.js works by defining a single route for each URL pattern. When a user navigates to a URL, Next.js renders the corresponding route and replaces the current page with the new one. However, this can lead to slow loading times and a less responsive user experience, especially when dealing with complex applications.
Parallel routes aim to solve this problem by allowing developers to define multiple routes that can be rendered together without the need to navigate between separate pages. This means that when a user clicks on a link or enters a URL, Next.js can render multiple routes simultaneously, displaying the content from each route side-by-side.
Defining Parallel Routes
To implement parallel routes in Next.js, developers use a feature called slots. Slots are defined using the @folder
naming convention and designed to structure content modularly. Each slot is passed as a prop to a corresponding layout.tsx
file. For instance, you might define slots for users, the team, and notifications in a dashboard application.
Each slot can be independently rendered within the layout, allowing for a dynamic and complex user interface.
How to use Parallel Routes
Let’s dive into the code to see this in action.
So, we want to create a simple demonstration app with a dashboard that displays content views like the users
page, notifications
page, and team
page using parallel routes.
If we want to do this traditionally using component composition, it’ll be like this:
// app/dashboard/layout.tsx
import User from "@/components/users";
import Team from "@/components/team";
import Notification from "@/components/notifications";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<div>{children}</div> {/* Content from page.tsx */}
<User /> {/* Component for user */}
<Team /> {/* Component for Team */}
<Notification /> {/* Component for notification */}
</>
);
}
While this traditional approach of component composition is effective, using parallel routes can achieve the same outcome with additional benefits.
Open up the terminal and create a new project. Change the directory to it and install Next.js using the following command:
npx create-next-app@latest
Give the project a name and follow the instructions to set up the app as you like. I’ll be using typescript with a bit of tailwind for this tutorial.
Once that is done, start up the development server using the following code:
npm run dev
Now that our development server is up and running, let’s showcase how parallel routes work.
The first thing to do is create the dashboard page. Navigate to app/page.tsx
, delete, and paste the following code:
export default async function Dashboard() {
return (
<div>
<h1 className="text-3xl font-bold">Dashboard</h1>
</div>
);
}
Here, we created a simple dashboard component.
We’ll need to define the slots, which are the users,
teams,
and notifications using the @folder
naming convention. These slots will act as the contents rendered or displayed inside our dashboard. To do this, we will define parallel routes for them.
Inside the app
folder, create a folder called @users,
and inside it, create a file called page.tsx
. Add the following code:
export default async function Users() {
return (
<div className="h-60 rounded-xl bg-yellow-800 p-10 text-white">
<h1 className="text-3xl font-bold">Users</h1>
</div>
);
}
In the code above, we created a users
component. We’ll also do that for the other two slots.
Create a folder called @team,
and a page.tsx
file inside it. Add the following code:
export default async function Team() {
return (
<div className="h-96 flex-1 rounded-2xl bg-green-800 p-10 text-white">
<h2 className="text-xl font-semibold">Team</h2>
</div>
);
}
Lastly, for the slots, inside the app
folder, create a folder called @notifications,
and paste this:
export default async function Dashboard() {
return (
<div className="h-96 flex-1 rounded-2xl bg-purple-800 p-10 text-white">
<h2 className="text-xl font-semibold">Dashboard</h2>
</div>
);
}
Now that we have our slots defined, the next step is to access the three slots we defined as attributes of the props object and render them dynamically. Navigate to the app/layout.tsx
file and change the code to this:
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
team,
notifications,
users,
}: {
children: React.ReactNode;
team: React.ReactNode;
notifications: React.ReactNode;
users: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<main className="container">
<section className="py-6">{children}</section>
<section className="flex flex-col h-full gap-6 ms-12">
{/* User component occupies the full width */}
<div className="flex gap-6">
<div className="w-full">{users}</div>
</div>
{/* Team and Notification components are stacked below */}
<div className="flex gap-6">
<div className="w-full">{team}</div>
<div className="w-full">{notifications}</div>
</div>
</section>
</main>
</body>
</html>
);
}
Each slot here is passed automatically to the layout as a prop, which allows us to create the dashboard.
Checking the result in the browser, we should have this: You can see that the dashboard has been successfully rendered using parallel routes.
Since we can achieve this traditionally, why use parallel routes anyway? What are the benefits?
Why Use Parallel Routes
One of the primary advantages of parallel routes is their ability to enable independent code rendering on the same URL and within the same view, utilizing slots. This offers a significant improvement over traditional routing methods, which were limited to linear rendering – meaning a single URL could only be associated with a single view.
As a result, developers had to resort to component composition techniques to create dynamic and adaptable user interfaces. One technique involved breaking down UI elements into smaller, modular components. These components could be combined and arranged in various ways to form complex layouts. While this approach has proven effective, managing complexity and ensuring optimal performance can still be challenging.
Parallel routes offer a fresh perspective on this challenge by allowing developers to define multiple routes that can coexist within the same view and still have its own set of slot definitions. This enables a more flexible and efficient approach to building dynamic user interfaces. Also, developers can now independently create and arrange discrete sections of a page while still enjoying the benefits of a unified routing system.
While all these are a great advantage to using parallel routes, the true benefits of using parallel routes include the following:
- Independent Route Handling
- Sub-navigation
Let’s expound on this:
Independent Routing
Parallel routes allow each route to be handled independently, allowing for granular control over loading and error states for different layout sections. This is particularly beneficial in scenarios where different sections of a page load at varying speeds or encounter unique errors.
For example, imagine a dashboard application that displays the users, the teams, and notifications(our example). If the data in the users’ section takes longer to load, the dashboard can display a loading spinner or a UI specifically for that section. At the same time, the teams and notifications remain interactive.
Similarly, if there’s an error fetching the teams section, an error message can be shown in that section without affecting the rest of the dashboard.
This level of detail in handling states improves the user experience by providing immediate feedback and simplifies debugging and maintenance by isolating issues to specific sections.
Let’s add some loading UI to our application to show how this works.
We’ll first need to create a delay function to add different delays to each slot’s load times.
In the app
folder, create a folder called lib
and a file called utils.ts
inside this folder. Add the following code:
export async function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
The delay function will be used to pause the execution of the code for a certain amount of time.
Create a file called loading.tsx
inside the app
directory and add the following code:
import React from "react";
export default function Loading() {
return (
<div className="h-60 flex-1 animate-pulse rounded-xl bg-sky-800 p-10 text-white" />
);
}
Let’s also give each of the slots their loading UI. We’ll start with the @users
slot. Inside it, create a file called loading.tsx
. Add the following code:
import React from "react";
export default function LoadingUsers() {
return (
<div className="h-60 flex-1 animate-pulse rounded-xl bg-yellow-800 p-10 text-white" />
);
}
Next up is the @team
slot. Inside it, create the loading.tsx
file. Add the following code:
export default function LoadingTeam() {
return (
<div className="flex h-96 flex-1 animate-pulse items-center justify-center rounded-2xl bg-green-800 p-10 text-white" />
);
}
Lastly, for the @notification
slot, create the loading.tsx
file inside it. Add the following code:
export default function LoadingDashboard() {
return (
<div className="flex h-96 flex-1 animate-pulse items-center justify-center rounded-2xl bg-purple-800 p-10 text-white" />
);
}
We need to update the page.tsx
for each slot:
@users/page.tsx
:
import { delay } from "@/app/lib/utils";
export default async function Users() {
await delay(2000);
return (
<div className="h-60 rounded-xl bg-yellow-800 p-10 text-white">
<h1 className="text-3xl font-bold">Users</h1>
</div>
);
}
@team/page.tsx
:
import { delay } from "@/lib/utils";
export default async function Team() {
await delay(3000);
return (
<div className="h-96 flex-1 rounded-2xl bg-green-800 p-10 text-white">
<h2 className="text-xl font-semibold">Team</h2>
</div>
);
}
For @notications/page.tsx
:
import { delay } from "@/app/lib/utils";
export default async function Dashboard() {
await delay(5000);
return (
<div className="h-96 flex-1 rounded-2xl bg-purple-800 p-10 text-white">
<h2 className="text-xl font-semibold">Dashboard</h2>
</div>
);
}
We updated each of these components to load at different milliseconds.
When we check the result in our browser, we should see we have a specific loading UI for each of our slots: This shows that these are independent pages that can fetch specific data, their loading UI, and error boundaries as separate components or pages. Depending on your use case, you can render them simultaneously, side by side, or conditionally.
Sub-navigation
This is another benefit of using parallel routes, as it facilitates a smooth sub-navigation experience within each route.
Think of each slot on your dashboard functioning as a mini-app, equipped with its own navigation and state management capabilities. This feature proves useful in a complex application like the dashboard example we created, where different sections serve their own purposes.
Let’s say each section of the dashboard, such as the users, team, or notifications, operates independently. Users can interact with each component separately without affecting the UI display or structure of the other sections.
For example, if we wish to implement sub-navigation within the @notification
slot, we must create a sub-folder and then feature the link: localhost:3000/subfolder
inside a header tag. We would navigate to the sub-folder and another link: localhost:3000
in the header that navigates back to the notification default view.
Let’s illustrate this concept using our example. Inside our @notification
slot, create a folder called settings,
and inside it, create a file called page.tsx
with the following content:
import { delay } from "@/lib/utils";
export const dynamic = "force-dynamic";
export default async function Settings() {
await delay(5000);
return (
<div className="h-96 flex-1 rounded-2xl bg-purple-800 p-10 text-white">
<h2 className="text-xl font-semibold">Settings</h2>
</div>
);
}
We need a header to help us navigate to the settings
segment. Create a folder called components
inside our’ app’ folder. Inside it, create a file called header.tsx
with the following code:
import Link from "next/link";
export default function Header() {
return (
<header className="py-10">
<div className="container">
<nav>
<ul className="flex items-center justify-center gap-10 text-lg font-bold uppercase tracking-wider text-gray-500">
<li>
<Link href="/">Home</Link>
</li>
<li>
<Link href="/settings">Settings</Link>
</li>
</ul>
</nav>
</div>
</header>
);
}
You’ll then need to add the header component to our layout. Go back to your layout.tsx
and update the code to this:
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import Header from "@/app/components/header";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
team,
notifications,
users,
}: {
children: React.ReactNode;
team: React.ReactNode;
notifications: React.ReactNode;
users: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<main className="container">
<section className="py-6">{children}</section>
<Header />
<section className="flex flex-col h-full gap-6 ms-12">
{/* User component occupies the full width */}
<div className="flex gap-6">
<div className="w-full">{users}</div>
</div>
{/* Team and Notification components are stacked below */}
<div className="flex gap-6">
<div className="w-full">{team}</div>
<div className="w-full">{notifications}</div>
</div>
</section>
</main>
</body>
</html>
);
}
Refresh the browser and navigate to the settings
segment in the headers. You’ll notice that only the dashboard
slot changes, as it’s the only one with the settings
segment.
The other slots do not possess that specific segment, so they will render their previously active state.
Unmatched routes
We encounter an unmatched route when the contents of a slot do not match the current URL. This occurs during sub-navigation when only one dashboard section matches the current route.
So by default, our slots are rendered when we navigate to localhost:3000
. But when we visit localhost:3000/settings
, only the notifications slot has a corresponding route. The other three slots—children, users, and team—all become unmatched.
When a page reload occurs, Next.js searches for a default.tsx file within the unmatched slots. If the file is absent, it returns a 404 error; otherwise, it displays the contents of the file.
When Next.js is unable to retrieve a slot’s current state, the default.tsx
file is used as a fallback for these unmatched slots. This allows us to render alternative content.
To prevent Next.js from returning a 404 error when accessing the /settings
route within the @notification
slot, add a default.tsx
file to each slot within the route segment, including the children
slot.
Let’s showcase that in the project. If you try to refresh the page while it’s in the settings
segment, you’ll encounter a 404 error. This happens because Next.js cannot find the default.tsx
file for the team
,users
, or dashboard
slots. To handle this, we need to create a default.tsx
file inside the teams
,users
, and the root app folder.
Inside the team
folder, create a default.tsx
file with the following code:
import { delay } from "@/app/lib/utils";
export default async function DefaultTeam() {
await delay(3000);
return (
<div className="h-96 flex-1 rounded-2xl bg-green-800 p-10 text-white">
<h2 className="text-xl font-semibold">Default Team</h2>
</div>
);
}
Inside the users
folder, create a default.tsx
file with the following code:
import { delay } from "@/app/lib/utils";
export default async function Users() {
await delay(2000);
return (
<div className="h-60 rounded-xl bg-yellow-800 p-10 text-white">
<h1 className="text-3xl font-bold">DefaultUsers</h1>
</div>
);
}
Inside the root app
folder, create a default.tsx
for it also.
import { delay } from "@/app/lib/utils";
export default async function Dashboard() {
await delay(1000);
return (
<div>
<h1 className="text-3xl font-bold">Default mainpage</h1>
</div>
);
}
Note that these changes won’t be reflected in the development mode due to an issue known by Next.js, but these changes can be seen if we are in production mode. Let’s do just that. Run the build command:
npm run build
And then start it in production with this command:
npm run start
With these changes, our application should now handle unmatched routes gracefully, providing a consistent user experience. This is a powerful feature of parallel routes in Next.js 14, allowing developers to manage complex routing patterns easily.
Conditional Routes
Parallel routes allow us to implement conditional routing. For example, if a user is authenticated, then the user is granted access to the dashboard layout. However, if the user is not authenticated, the user can be redirected to the login page. Here’s an example of how we can utilize parallel routes:
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import Header from "@/app/components/header";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
function handleLogin() {
// Implement your login logic here
console.log("Login initiated");
}
export default function RootLayout({
children,
team,
notifications,
users,
login,
}: {
children: React.ReactNode;
team: React.ReactNode;
notifications: React.ReactNode;
users: React.ReactNode;
login: React.ReactNode;
}) {
// Replace this with your actual authentication logic.
const isLoggedIn = false;
return isLoggedIn ? (
<html lang="en">
<body className={inter.className}>
<main className="container">
<section className="py-6">{children}</section>
<Header />
<section className="flex flex-col h-full gap-6 ms-12">
{/* User component occupies the full width */}
<div className="flex gap-6">
<div className="w-full">{users}</div>
</div>
{/* Team and Notification components are stacked below */}
<div className="flex gap-6">
<div className="w-full">{team}</div>
<div className="w-full">{notifications}</div>
</div>
</section>
</main>
</body>
</html>
) : (
<div>
{/* Render the login component */}
{login}
{/* Optionally, include a button or form to initiate the login process */}
<button onClick={handleLogin}>Log In</button>
</div>
);
}
In the code above, we implemented conditional routing to manage the user interface based on the user’s authentication state. Specifically, if the user is logged in, the application renders a layout with a header and its content. If the user is not logged in, it renders a login component instead.
Conclusion
Parallel routes in Next.js 14 provide a robust solution for managing complex routing patterns in dynamic applications. They offer a way to handle active states and navigation, handle unmatched routes, implement conditional routing, and define independent loading and error UI for each route.
By leveraging these features, developers can build more sophisticated and user-friendly applications with Next.js.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.