One Practical Application of Next.JS Parallel and Intercepting Routes: Better UX with Modals.

Photo by BENCE BOROS on Unsplash

One Practical Application of Next.JS Parallel and Intercepting Routes: Better UX with Modals.

Featured on Hashnode

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:

  1. Slots

  2. 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:

  1. Implicit Slots: The children slot is an implicit slot that doesn't need to be created explicitly.

  2. Explicit Slots: Additional slots like @dashboard need to be created explicitly.

Navigation with Parallel Routes

NextJS handles navigation with parallel routes in two ways:

  1. 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.

  2. 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 root app 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:

  1. Root Page with an Input Field:

    Root Page Image

    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>
    
  2. 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.

    Modal Image

    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.

  3. 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.

    Login Page Image

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.

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.

  1. Create a folder (new slot) named @modal inside the app directory.

  2. Inside @modal, create a folder (.)login and add a page.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!