Beyond Pages: Build an Interactive Resume with Layout-Driven Design in Next.js

*Navigate with parallel routes and route interception in the App Router*

Building a resume for your job search?

Modals let users quickly preview a resume sample without losing context. They can explore and easily navigate back when browsing multiple samples.

Read on to learn how to showcase your resume samples in a modal overlay in a Next.js app.

By the end of this tutorial, you’ll have an app where:

  1. The user is on /resume page

  2. They click on a resume sample which links to a new route

  3. Next.js intercepts the route and displays content inside a modal slot

  4. The modal appears as an overlay due to styling

  5. The user clicks a button to return to the /resume page

Your app will also display a dedicated page for the sample when you visit the route directly – ideal for bookmarking or showcasing full content.

Bonus points! You’ll also get a feel for some key routing features in Next.js apps that use the App router. If you want a sneak peak, you can find the final code for the interactive resume in the nextjs-navigation Github repository.

Let’s get started!

Prerequisites

Create a Next.js application with the app router using the Automatic Installation instructions. Make sure to include Tailwind and Typescript.

Create a resume page

In Next.js projects that use the App Router, each route corresponds to a folder in the /app directory.

First, we’ll create a resume page by adding a resume folder, which maps to the /resume route: app/ ├── resume/ │ └── page.tsx

Add a card representing a portfolio project by copying the following to your app/resume/page.tsx file. import Link from "next/link";

export default function Resume() { return ( <div className="flex flex-col gap-7 bg-gray-100 rounded-lg shadow-md p-10 m-10 w-1/4 max-w-sm"> <h5 className="text-2xl text-gray-900">Sample Project</h5> <p className="font-normal text-gray-500"> Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua </p> <Link href={`/`} className="text-blue-400"> Learn more </Link> </div> ); }

When you start your development server and visit the /resume path, you should see the project card:

Sample Card

Create a modal with parallel routes

To create the modal overlay, we’re going to use parallel routes. You can use a parallel route to introduce a UI region (or slot) into your application where your modal can live.

To start, create a layout.tsx file inside your resume folder. app/ ├── resume/ │ ├── layout.tsx │ └── page.tsx

Inside the layout.tsx file, we’re going to define a slot for the modal using the layout’s props. The props tell Next.js that there are two UI regions that we want to fill – the modal and the remaining content on the page (via the children prop). import React from "react";

interface Props { modal: React.ReactNode; children: React.ReactNode; }

export default function ResumeLayout({ modal, children }: Props) { return ( <div> {children} {modal} </div> ); }

Next, we’re going to create a @modal subfolder within the resume folder to fill the slot we created. The name of the subfolder should match the prop name. The @modal segment won’t be part of the URL path that you visit, but Next.js will place its contents inside the modal slot we defined in layout.tsx. app/ ├── resume/ │ ├── @modal/ // folder name matches the prop name │ │ └── page.tsx │ ├── layout.tsx │ └── page.tsx

Add the sample description to app/resume/@modal/page.tsx.

export default function Modal () { return ( <dialog open> <h1>Sample project</h1> <p>Sample summary</p> </dialog> ); }

When you visit the resume page at /resume, you will see the modal slot rendered below the main page content, since that’s how we defined the layout.tsx page.

Tip! To see changes, you’ll need to restart your development server after you change the routing tree or layout throughout this tutorial. Next.js rereads the routing tree and updates its configuration when you do this.

Parallel Route

You can then use styling in app/resume/@modal/page.tsx to make the modal appear as an overlay, i.e. on top of the main page content: export default function Modal () { return ( <dialog className="fixed top-[15%] left-1/7 w-3/4 h-3/4 bg-gray-300 p-8 rounded border-none shadow-lg z-[9999]" open> <h1 className="text-2xl mb-5">Sample project</h1> <p>Sample summary</p> </dialog> ); }

Your resume page should now look like this:

Modal Overlay

Trigger the modal with intercepted routes

Currently, the modal renders whenever we are on the /resume page, but we want to have the modal appear only when a user clicks on the sample. Otherwise, we want it hidden.

One solution is to have the modal slot empty by default. Then, when a user clicks on the sample, we can navigate them to a new route, which Next.js will intercept and load inside the modal slot instead of as a full page.

Let’s explore how intercepting routes work!

First, update the resume/@modal/page.tsx file to return null: export default function Modal () { return null; } You shouldn’t have the modal overlay anymore when you visit /resume.

Then, create the new route, /sample, that loads inside the modal slot. The parentheses denote the intercepted route.

app/ ├── resume/ │ ├── @modal/ │ │ ├── page.tsx │ │ └── (...)sample/ │ │ └── page.tsx │ ├── layout.tsx │ └── page.tsx

Next, add the modal overlay to the /resume/@modal/(...)sample/page.tsx file you just created.

export default function SampleModal() { return ( <dialog className="fixed top-[15%] left-1/7 w-3/4 h-3/4 bg-gray-300 p-8 rounded border-none shadow-lg z-[9999]" open> <h1 className="text-2xl mb-5">Sample project</h1> <p>Sample summary</p> </dialog> ); }

Next.js can intercept the route from any child or nested page within the /resume folder, since the layout file that declared the modal lives there.

In your /resume/page.tsx file, update the Learn More link to direct users to the route for the modal overlay (i.e. /sample).

import Link from "next/link";

export default function Resume() { return ( <div className="flex flex-col gap-7 bg-gray-100 rounded-lg shadow-md p-10 m-10 w-1/4 max-w-sm"> <h5 className="text-2xl text-gray-900">Sample Project</h5> <p className="font-normal text-gray-500"> Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua </p> <Link href={`/sample`} className="text-blue-400"> Learn more </Link> </div> ); }

Restart your dev server and try clicking on the Learn More link on the resume page. You should see your modal overlay without leaving the resume page context.

When the modal is open, notice that the browser URL is /sample, not /resume/sample. Even though the intercepted route file is nested inside the resume folder, it's not actually part of the resume route path. Intercepted routes use only the path after the parentheses in its folder structure and don’t include the path where it is rendered.

To close out the modal, we’re going to add a link to the modal overlay in /app/resume/@modal/(...)sample/page.tsx that will take you back to /resume page. import Link from "next/link";

export default function SampleModal() { return ( <dialog className="fixed top-[15%] left-1/7 w-3/4 h-3/4 bg-gray-300 p-8 rounded border-none shadow-lg z-[9999]" open> <h1 className="text-2xl mb-5">Sample project</h1> <p>Sample summary</p> <Link href="/resume" className="fixed top-[80%] right-[15%] z-[10000] inline-flex items-center px-4 py-2 text-sm font-medium text-blue-800" > Close </Link> </dialog> ); }

Add a full sample page with non-intercepted routes

If you visit the /sample directly or refresh the page when you’re viewing the modal, you should see a 404 error. That’s because Next.js uses its default routing behavior when you visit a route directly or refresh the page (no interception occurs!).

We’ll need to define a /sample route in your app folder which will render content when the sample route is not intercepted.

In any case, having a dedicated sample page is ideal if you want people to bookmark your resume sample.

Create a new /sample folder inside the app directory:

app/ ├── resume/ │ ├── @modal/ │ │ ├── page.tsx │ │ └── (...)sample/ │ │ └── page.tsx │ ├── layout.tsx │ └── page.tsx ├── sample/ │ └── page.tsx

Add full content for the sample to the app/sample/page.tsx file. export default function SamplePage() {

return ( <> <h1 className="text-2xl mb-5">Sample project</h1> <p>Sample content on full page load </p> </> ); } Now when you visit the /sample page directly in the browser, you should see content that Next.js loads as a full page. Later, we’re going to make this into a dedicated page for your sample that you can access by deep link.

Remember that Next.js only intercepts the /sample route from any child or nested page within the /resume folder. If we try to access /sample from a different location, we’ll also get the non-intercepted route.

Pro tip! Check out the file convention when you name your intercepted route folder. It’s based on the position of the non-intercepted route folder relative to that of the intercepted route.

Bonus: showcase multiple samples with dynamic routes

You’ll probably want your /resume page to showcase multiple samples.

To follow along, create a sampleData.ts file in the root directory with sample data for multiple samples: type sampleData = { title: string; description: string; slug: string; summary: string; content: string; };

const samples: sampleData[] = [ { "title": "Sample Project 1", "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua", "slug": "sample-1", "summary": "Sample summary for project 1", "content": "Sample content for project 1" }, { "title": "Sample Project 2", "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua", "slug": "sample-2", "summary": "Sample summary for project 2", "content": "Sample content for project 2" } ];

export default samples;

Import the sample data into your /resume/page.tsx file and update the resume page to include all the samples:

import Link from "next/link";

import samples from "@/sampleData";

export default function Resume() { return ( <section className="flex items-start justify-center gap-7"> {samples.map((sample, i) => ( <div key={i} className="flex flex-col gap-7 bg-gray-100 rounded-lg shadow-md p-10 m-10 w-1/4 max-w-sm"> <h5 className="text-2xl text-gray-900">{sample.title}</h5> <p className="font-normal text-gray-500">{sample.description}</p> <Link href={`/samples/${sample.slug}`} className="text-blue-400"> Learn more </Link> </div> ))} </section> ); }

Multiple Samples

Notice in our code that we no longer want the Learn more link to always point to the /sample route. We want the first sample to appear under a /samples/sample-1 path, the second sample to appear under a /samples/sample-2 path, etc.

We’ll have to adjust our folder structure to account for this. But instead of creating a folder for each route, we can use a dynamic segment in the folder structure to represent a parameter: app/ ├── resume/ │ ├── @modal/ │ │ ├── page.tsx │ │ └── (...)samples/ │ │ └── [sample]/ │ │ └── page.tsx │ ├── layout.tsx │ └── page.tsx ├── sample/ │ └── page.tsx

The modal for each sample lives in the app/resume/@modal/(...)samples/[sample]/page.tsx file.

The dynamic segment captures the slug as a parameter, which you can use to access content for a particular sample.

We’re going to update the app /resume/@modal/(...)samples/[sample]/page.tsx to render sample-specific content based on the parameter: import Link from "next/link";

import samples from "@/sampleData";

type Props = { params: { sample: string } }

export default async function SampleModal( {params}: Props ) { const { sample: slug } = await params; const sample = samples.find(s => s.slug === slug);

return ( <dialog className="fixed top-[15%] left-1/7 w-3/4 h-3/4 bg-gray-300 p-8 rounded border-none shadow-lg z-[9999]" open> <h1 className="text-2xl mb-5">{sample?.title}</h1> <p>{sample?.summary}</p> <Link href="/resume" className="fixed top-[80%] right-[15%] z-[10000] inline-flex items-center px-4 py-2 text-sm font-medium text-blue-800" > Close </Link> </dialog> ); }

Restart the dev server. Now when you click on each sample, you should see info for the sample you clicked under its corresponding path:

We’ll also want the non-intercepted version of the route to point to the updated paths and load sample-specific content.

Update your folder structure to include dynamic segment for the non-intercepted route:

app/ ├── resume/ │ ├── @modal/ │ │ ├── page.tsx │ │ └── (...)samples/ │ │ └── [sample]/ │ │ └── page.tsx │ ├── layout.tsx │ └── page.tsx ├── samples/ │ └── [sample] │ └── page.tsx

Then update the app /resume/samples/[sample]/page.tsx to render sample-specific content based on the parameter: import samples from "@/sampleData";

type Props = { params: { sample: string }; };

export default async function SamplePage( { params }: Props ) { const { sample: slug } = await params; const sample = samples.find(s => s.slug === slug);

return ( <> <h1 className="text-2xl mb-5">{sample?.title}</h1> <p>{sample?.content}</p> </> ); } Try visiting /samples/sample-1 directly in the browser. You should see info for the first sample on full page load:

Keep building!

Learn more about the file system in Next.js apps that use the App router.