Photo by BENCE BOROS on Unsplash
One Practical Application of Next.JS Parallel and Intercepting Routes: Better UX with Modals.
I have been using Next.js for all my projects for about a year now, and there is always something new to learn. Even till today, I keep on discovering new ways to improve my websites using the features provided with Next.Js, so much to learn and so much fun...
Recently, I learned about some lesser-known Next.js routing concepts: parallel routes and intercepting routes. I spent some time exploring how I could use these in my projects. In this blog, I will cover one such use case: how to use these routing techniques with modals to create a better user experience.
Let's start with a quick explainer for both the concepts:
Parallel Routes
Parallel Routes allow us to simultaneously or conditionally render multiple pages within the same layout. They're particularly useful for highly dynamic sections in applications, such as feeds on social media sites.
There are two important components to it:
Slots
How they work with navigation and the
default.js
page.
Understanding Slots
Parallel routes are created using named slots. Slots are defined with the @folder-name
convention, for example, if we create @dashboard
or @teams
folder and add a page.tsx
in both folders. These are considered as slots to the shared parent layout (the nearest layout.tsx
). The layout component will then accept additional props corresponding to these slots, which can be rendered in parallel alongside the children
prop.
Here's an example:
export default function Layout({
children,
dashboard,
teams,
}: {
children: React.ReactNode
dashboard: React.ReactNode
teams: React.ReactNode
}) {
return (
<>
{children}
{dashboard}
{teams}
</>
)
}
It's important to note that slots are not route segments and do not affect the URL structure. For example, for @dashboard/views
, the URL will be /views
since @dashboard
is a slot.
If you notice there are two types of slots:
Implicit Slots: The
children
slot is an implicit slot that doesn't need to be created explicitly.Explicit Slots: Additional slots like
@dashboard
need to be created explicitly.
Navigation with Parallel Routes
NextJS handles navigation with parallel routes in two ways:
Soft Navigation (using the
Link
component within the website):NextJS performs a partial render, changing the subpage within a slot.
It maintains the other slots' active subpages, even if they don't match the current URL.
Hard Navigation (page reload or entering the URL in the browser):
NextJS can't determine the active state for slots that don't match the current URL.
It will render the
default.js
file if present for that slot, or return a 404 error if it's not present.
For example, given this folder structure:
├── src
│ ├── app
│ │ ├── @dashboard
│ │ │ └── page.tsx
│ │ ├── favicon.ico
│ │ ├── globals.css
│ │ ├── abc
│ │ │ └── page.tsx
│ │ ├── layout.tsx
│ │ └── page.tsx
Navigating to <url>/
works fine, but navigating to <url>/abc
will return a 404 error because abc
is not defined in @dashboard
. To avoid this, add a default.js
file in @dashboard
, which will be rendered for all undefined routes in @dashboard
that are present in other slots.
For more information on parallel routes, refer to the official NextJS documentation on parallel routes.
Intercepting Routes
Intercepting routes allow us to load a route from another part of your application within the context current layout. This is useful when you want to display the content of a different route without changing the user's context.
For example, when a user clicks on a photo in a feed, you can display the photo in a modal overlaying the feed. In this case, NextJS intercepts the /photo/123
route, masks the URL, and render a modal, overlaying the content present in the /feed
route. However, when navigating to the photo directly (via a shared URL or page refresh), it does not intercepts it and render the page.tsx
inside /photo/[id]
folders as usual.
How to Use Intercepting Routes
Intercepting routes are defined using the (..)
convention, similar to the relative path convention ../
but for route segments. You can use:
(.)
to match segments on the same level(..)
to match segments one level above(..)(..)
to match segments two levels above(...)
to match segments from the rootapp
directory
For example, to intercept the photo
segment from within the feed
segment, create a (..)photo
directory.
So now when we navigate to the /photo
from inside the /feed
it will intercept the route, and render the page.tsx
inside (..)photo
created inside the feed
folder, instead of the page.tsx
present inside photo
folder, but it will render the page.tsx
for the /photo
route normally on page refresh or when navigating via shared URL though.
For further reading, check out the Next.js documentation on intercepting routes.
Practical Application
Intercepting routes and parallel routing offer several benefits, including:
The ability to split a single layout into multiple slots, making the codebase more manageable.
Independent route handling for different slots.
Sub-navigation capabilities.
In this blog, we'll focus on how to implement better modals using intercepting routes.
Overview of the application:
Root Page with an Input Field:
When the "login" button is clicked, it redirects to the
/login
route. Here's the link component for reference:<Link href={"/login"} className="px-4 py-2 bg-black text-white rounded-md" > Login! </Link>
Rendering a Modal Instead of Navigating:
Instead of directly navigating to the login route and rendering the login page there, A modal overlay on the same page while preserving the context of the current page.
Notice that the URL is
/login
. If you click outside the modal or on the close icon, it navigates back to the/
route, preserving the input field's content.Handling Direct Navigation:
If the page is reloaded or if you directly load
http://localhost:3000/login
in the browser, the login page will render as usual.
Steps to implement it
Step 1. Setting up a reusable modal component
We'll use the Shadcn UI library for the modal component to keep things simple.
Follow the installation guide on Shadcn UI's website.
Once installed, add the dialog component by following the instructions on this page: Dialog Component.
Next, create a file named modal.tsx
inside the components
folder with the following code:
// We are using useRouter hence this needs to be a client component.
"use client";
import { Dialog, DialogContent, DialogOverlay } from "@/components/ui/dialog";
import { useRouter } from "next/navigation";
export default function Modal({
// We pass the children and className as a prop to keep modal reusable.
children,
className
}: {
children: React.ReactNode
className?:string
}) {
const router = useRouter();
// This will change the URL to the previous state, for example if we have navigated to the login page from `/` route, when the function will trigger the URL will be changed from `/login` to `/`.
function handleOpenChange() {
router.back();
}
return (
// We are using the shadcn ui's dialog component
<Dialog
// It will be open by default.
defaultOpen={true}
// It needs to be set in order to be able to use onOpenChange handler (https://www.radix-ui.com/primitives/docs/components/dialog#dialog)
open={true}
// This will trigger whenever we close the modal by clicking outside the modal or by clicking on the cross button
onOpenChange={handleOpenChange}
>
<DialogContent
className={className || ""}
>
{children}
</DialogContent>
</Dialog>
)
}
Step 2: Creating a login route
Create a folder named login
in the root (inside the app
directory) and add a page.tsx
file with this code:
"use client";
import { useState } from 'react';
export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e:React.FormEvent) => {
e.preventDefault();
console.log('Data:', { username, password });
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="bg-white p-8 rounded-lg shadow-md w-96">
<h2 className="text-2xl font-bold mb-6 text-center text-gray-800">Login</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700">Username</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
required
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">Password</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
required
/>
</div>
<button
type="submit"
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Log In
</button>
</form>
</div>
</div>
);
}
It is a simple login page.
Step 3: Create intercepting routes:
Now create a let's create a separate slots to handle intercepting routes and displaying the modals instead.
Create a folder (new slot) named
@modal
inside theapp
directory.Inside
@modal
, create a folder(.)login
and add apage.tsx
file.
The structure should look like this:
├── src
│ ├── app
│ │ ├── @modal
│ │ │ ├── (.)login
│ │ │ │ └── page.tsx
│ │ ├── favicon.ico
│ │ ├── globals.css
│ │ ├── layout.tsx
│ │ └── page.tsx
Inside (.)login/page.tsx
, add the following code:
"use client";
import Modal from "@/components/modal";
import { useState } from "react";
export default function InterceptedLogin() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
console.log('Data:', { username, password });
};
return (
<Modal
className="w-96"
>
<div className=" rounded-lg w-full">
<h2 className="text-2xl font-bold mb-6 text-center text-gray-800">Intercepted Login</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700">Username</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
required
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">Password</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
required
/>
</div>
<button
type="submit"
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Log In
</button>
</form>
</div>
</Modal>
)
}
This is the content that will be rendered when the /login
route will be intercepted, it is same as the login page just wrapped inside the modal.
Step 4: Adding the @modal
Slot to layout.tsx
Modify layout.tsx
at root inside the app
directory, to include the @modal
slot:
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,
// Notice the layout automatically accepts a new prop named modal
modal
}: Readonly<{
children: React.ReactNode;
modal:React.ReactNode
}>) {
return (
<html lang="en">
<body className={inter.className}>
{children}
// We will render it alongside the clildren.
{modal}
</body>
</html>
);
}
Now, if we run the application and navigate to http://localhost:3000
, we'll encounter a 404
page. This happened because we haven't yet defined any route for the @modal
slot other than (..)login
. As a result, Next.js can't determine the active state for the modal
slot, for the root URL or any other URL except /login
. To resolve this, we need to add a default.jsx
file inside the @modal
folder.
However, we don't need to render anything in this slot except to handle navigation to the login page, right? To achieve this, we can simply return null
in the default page. This approach prevents anything from rendering in the @modal
slot, while still allowing the modal to appear when navigating to the /login
route.
To implement this, create a default.tsx
file inside the @modal
folder and add the following code:
export default function ModalsDefaultPage(){
return null;
}
With this setup, our code will function as expected.
One thing that can still be unclear is, why we need to create the intercepting route in a parallel route, rather than directly at the root?? If you try placing it at the root, the modal will still open, but the issue arises when you close the modal: the state of the current page won't be preserved, such as the input field being cleared in our example.
This occurs because of how Next.js handles navigation for parallel routes. With "soft navigation," Next.js only changes the subpage within the specified slots, preserving the state for all other slots. If we place the intercepting route at the root, it will change the subpage, causing the current page to lose its state when navigating back. This is why we use a parallel route and return null
from its default.tsx
—so that losing the state for this page doesn't matter, as there is no state to preserve in the first place.
Conclusion
In this example I have only demonstrated example for intercepting the login route, but we can also intercept other routes as well from the same @modal
slot.
Parallel routes and intercepting routes are powerful features in NextJS that can enhance the user experience as well as developer experience for your application if used properly. By understanding and implementing these concepts, we can create more dynamic and interactive interfaces.
For reference I have provided all the code discussed above in a accompanying GitHub repository: github.com/adityabhattad2021/Parallel-Inter.. Feel free to give it a star.
Thankyou for reading this far, have a great day!