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:
The user is on
/resume
pageThey click on a resume sample which links to a new route
Next.js intercepts the route and displays content inside a modal slot
The modal appears as an overlay due to styling
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:

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.

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:

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>
);
}

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.