Exploring Parallel Routes in Next.js: Practical Journey

by Liben Hailu
18 mins read

Explore the latest feature in Next.js 14 with the introduction of parallel routes, allowing for more efficient and dynamic multi-page rendering within a single layout.


Parallel Routes

Parallel routes are an advanced routing mechanism that allows you to simultaneously render one or more pages within a single layout file. They are useful for highly dynamic sections of the application.

How to Use Parallel Routes

Previously, when using Next.js, even though you had multiple rendering strategies, it was difficult to mix rendering strategies. So, you had to choose between dynamically rendered pages or static pages that were prerendered at build time. With Next.js 14, you can make some sections of your static pages dynamically rendered, or you can render some static content on your dynamically rendered application. This approach is called Partial Rendering.

Let's say you are building a dashboard website which allows users to view user activities, active users, and users for now. The traditional approach is to create a component for each section, which would look like this on our page.

/app/(site)/dashboard/page.tsx
export default function DashboardPage() {
  return (
    <main>
      <div className="grid min-h-screen items-start gap-6 px-4 pb-6 md:gap-10 md:px-6 lg:grid-cols-6 lg:items-center lg:gap-0 lg:pb-0 xl:gap-12">
        <div className="space-y-4 lg:col-span-3">
          <Suspense fallback="Loading...">
            <Users />
          </Suspense>
        </div>
        <div className="space-y-4 lg:col-span-3">
          <Suspense fallback="Loading...">
            <Activities />
          </Suspense>
        </div>
      </div>
    </main>
  );
}

As you can see, we can have boundaries like suspense to show loading indicators, and we can also have an error boundary to display errors that occurred in that specific section. However, we can achieve the same result with some additional benefits using parallel routes.

Now let's extract our components into slots.

Slot: Parallel routes in Next.js are defined using a feature known as slots. Slots help structure our content in a modular fashion, Slots are defined with the @folder_name convention.

Let's create a folder named @activities. Inside, let's create a page.tsx file and paste our component here.

 
async function getActivityData(): Promise<Activity[]> {
    const res = await fetch("http://localhost:4000/activities");
 
    if (!res.ok) {
        throw new Error("Failed to fetch data");
    }
 
    return res.json();
}
 
export default async function Activities() {
    const data = await getActivityData();
    return <>
        <h1 className="text-3xl font-bold">Recent Activity</h1>
        <Card>
            <CardContent className="p-4">
                <div className="grid gap-4 text-sm">
                    {data.map(activity =>
                        <div key={activity.user} className="flex items-center gap-4">
                            <div className="flex-1">
                                <h3 className="font-bold text-base">{activity.user}</h3>
                                <p className="text-sm text-gray-500 dark:text-gray-400">
                                    {activity.action}
                                </p>
                            </div>
                            <div className="text-right">
                                <time className="text-sm text-gray-500 dark:text-gray-400">
                                    {new Date(new Date(activity.timestamp).getTime() - (1 * 60 * 60 * 1000)).toLocaleString('en-US', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', timeZone: 'UTC' })}
                                </time>
                            </div>
                        </div>)}
                </div>
            </CardContent>
        </Card>
    </>
}

Let's do the same for users slot.

/app/(site)/dashboard/@users/page.tsx
 
async function getUserData(): Promise<User[]> {
    const res = await fetch("http://localhost:4000/users");
 
    if (!res.ok) {
        throw new Error("Failed to fetch data");
    }
 
    return res.json();
 
}
export default async function Users() {
    const data = await getUserData();
    return <>
        <h1 className="text-3xl font-bold">All Users</h1>
        <Card>
            <CardContent className="p-4">
                <div className="grid gap-4 text-sm">
                    {data.map(user => <div key={user.email} className="flex items-center gap-4">
                        <div className="flex-1">
                            <h3 className="font-bold text-base">{user?.name}</h3>
                            <p className="text-sm text-gray-500 dark:text-gray-400">
                                {user.email}
                            </p>
                        </div>
                        <div className="text-right">
                            <time className="text-sm text-gray-500 dark:text-gray-400">{user.role}</time>
                        </div>
                    </div>)}
 
                </div>
            </CardContent>
        </Card>
    </>;
}
 

"Now let's encapsulate loading and error states for our slots. We can go inside our slot folder and create loading.tsx and error.tsx."

Let's add a loading component for activities slot.

export default function Loading() {
    return (
        <div className="flex justify-center items-center h-full text-secondary-foreground">
            <div className="flex items-center space-x-2">
                <span>Loading...</span>
            </div>
        </div>
    );
}

Let's add an error component for activities slot.

"use client";
 
export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // Log the error to an error reporting service
    console.error(error);
  }, [error]);
 
  return (
    <div className="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
      <div className="max-w-md w-full space-y-8">
        <div>
          <X className="w-12 h-12 text-primary mx-auto" />
          <h2 className="mt-6 text-center text-2xl md:text-3xl font-extrabold">
            Oh no, something went wrong!
          </h2>
          <p className="mt-2 text-center text-sm text-muted-foreground">
            Oops! Something went wrong. Please try again.
          </p>
        </div>
        <div className="mt-5">
          <Button
            className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium "
            onClick={
              // Attempt to recover by trying to re-render the segment
              () => reset()
            }
          >
            Try again
          </Button>
        </div>
      </div>
    </div>
  );
}

Let's do the same for users slot.

Now we have seen the beauty of slots. Each section can fail independently with its own specific error message, making it modular and maintainable for developers.

Now let's add sub-navigation, one of the cool features in parallel routing. Let's say the client asked you to include an option to view active users while also retaining the option to view all users.

In parallel routing, each slot acts like a mini-application. Even though slots are not route segments, we can create a route segment inside slots. This can be useful when there are tabs or navigation links.

Let's see it in action,

Let's create a folder named active inside our users slot and create a page.tsx.

 
async function getUserData(): Promise<User[]> {
    const res = await fetch("http://localhost:4000/activeUsers", { cache: "no-cache" });
 
    if (!res.ok) {
        throw new Error("Failed to fetch data");
    }
 
    return res.json();
}
export default async function ActiveUsers() {
    const data = await getUserData();
    return (
        <>
            <div className="flex justify-between items-center">
                <h1 className="text-3xl font-bold">Active Users</h1>
                <Button asChild>
                    <Link href="/dashboard">All Users</Link>
                </Button>
            </div>
            <Card>
                <CardContent className="p-4">
                    <div className="grid gap-4 text-sm">
                        {data.map((user) => (
                            <div key={user.email} className="flex items-center gap-4">
                                <div className="flex-1">
                                    <h3 className="font-bold text-base">{user?.name}</h3>
                                    <p className="text-sm text-gray-500 dark:text-gray-400">
                                        {user.email}
                                    </p>
                                </div>
                                <div className="text-right">
                                    <time className="text-sm text-gray-500 dark:text-gray-400">
                                        {user.role}
                                    </time>
                                </div>
                            </div>
                        ))}
                    </div>
                </CardContent>
            </Card>
        </>
    );
}
 

Let's add a link to navigate to the active users route segment.

export default async function Users() {
    const data = await getUserData();
    return (
        <>
            <div className="flex justify-between items-center">
                <h1 className="text-3xl font-bold">All Users</h1>
                <Button asChild>
                    <Link href="/dashboard/active">Active Users</Link>
                </Button>
            </div>
            <Card>
            ...
            </Card>
        </>

Now when we click the button to navigate to active route http://localhost:3000/dashboard/active every thing is retained only users slot is changed.

Now if we refresh the page when it is on http://localhost:3000/dashboard/active we will get 404 page what is the issue.

Active State, Navigation and Unmatched Routes

Next.js keeps track of the active state of sub-pages. However this depends on the navigation.

Soft Navigation: During client-side navigation, Next.js will perform a partial render, changing the subpage within the slot while maintaining the other slot's active subpages, even if they don't match the current URL. Hard Navigation: After a full-page load (browser refresh), Next.js cannot determine the active state for the slots that don't match the current URL. Instead, it will render a default.js file for the unmatched slots, or 404 if default.js doesn't exist.

To resolve our issue, let's go to our slots and create a default.tsx file and mirror our page.tsx by copying and pasting the content.

Benefits of Parallel Routes

  • Modularity and Maintainability: Parallel routes can be very helpful in modularizing complex applications, especially when different teams are working on different sections of the application.
  • Independent Route Management: Each slot handles its own state and data. This can be helpful when rendering sections that fetch data taking a long time to load or pages with low page load speed without affecting other sections.
  • Sub-navigation involves navigating between subpages while maintaining the state of the subpages.

Source code can be found here