Modal with a real URL
In this lesson you ship the “New invoice” form, and you ship it twice over from a single set of files: from /invoices, clicking “New invoice” opens the form as a modal floating over the list with the URL at /invoices/new; visiting that exact URL directly, refreshing it, or Cmd+clicking the link renders the same form as a full standalone page instead. One link, two presentations, decided by how the user arrived.
Your mission
Section titled “Your mission”This is the modal-with-real-URL pattern, and it is the production default for any “form that could just as well be its own page” — new invoice, edit profile, compose message. The instinct a lot of developers reach for first is a useState boolean: a const [open, setOpen] = useState(false) somewhere on the list, flipped by the button. That works until the moment someone wants to share the form, reload it, or open it in a new tab — and then it falls apart, because the form’s existence lives in a React state variable that no URL can address. The whole point of this lesson is to put the URL in charge instead. When /invoices/new is the source of truth for whether the form is showing, the form earns shareability, refreshability, and Cmd+click for free, with no client state owning that decision at all.
The “New invoice” link is already in the list header — you wired it in Server-rendered list and detail, where it pointed at /invoices/new and navigated to a placeholder full page. You change none of that. What you add this lesson is an interception: a route that catches the soft navigation to /invoices/new and renders the form as a modal over the list, while leaving every other way of reaching that URL untouched.
That brings the one constraint that shapes the whole solution: an intercepting route is always paired with its non-intercepting twin. The interceptor only fires on client-side soft navigation — a <Link> click from within the app. A direct visit, a refresh, and a Cmd+click are not soft navigations; the browser asks the server for /invoices/new from scratch, and the App Router resolves that to the real page at new/page.tsx. Skip that twin and all three of those paths break — a refresh would 404, a shared link would open nothing. So build the twin first. It is the floor every non-soft entry lands on, and the interceptor is just an overlay that sits on top of it for the one case where the user is already inside the app.
Two more decisions worth naming before you start. First, closing the modal is a navigation, not a state toggle: dismissing it calls router.back(), which pops the /invoices/new entry off the history stack and drops the user back on /invoices with a clean back button — flipping a boolean would leave a dangling /invoices/new URL in the bar with nothing behind it. Second, keep the 'use client' boundary tight: the dialog needs a client component because it reads the router and handles a close event, but the intercepting page itself stays a thin Server Component that just composes the two pieces.
One trade is deliberately on the table, not a bug to fix. Refreshing while the modal is open renders the full page and drops the list underneath — because a refresh is not a soft navigation, so the twin takes over. That is the accepted shape in 2026 Next.js. Preserving the modal and its underlay across a refresh would mean a parallel @modal slot, which is more machinery than this surface needs, and it is out of scope here.
/invoices opens the form as a modal over the list, with the URL at /invoices/new./invoices/new in a fresh tab or load renders the full-page form, not the modal.Cmd+clicking the “New invoice” link opens the full page in a new tab./invoices and leaves the browser history clean.Coding time
Section titled “Coding time”Build it against the brief and Lesson 3.test.ts before you open the solution. Three files, all currently TODO(L3) stubs: the full-page twin, the dialog wrapper, and the intercepting page. Start with the twin.
Reference solution and walkthrough
Build them in the order the framework resolves them: the twin is what every non-soft entry lands on, the dialog is the modal shell, and the intercepting page is the thin overlay that brings them together.
The non-intercepting twin
Section titled “The non-intercepting twin”src/app/invoices/new/page.tsx is the real page at /invoices/new — the one the server hands back on a direct visit, a refresh, or a Cmd+click. It is a plain Server Component: a centered <section> with a header, the provided <InvoiceForm />, and a “Cancel” link back to the list. Nothing about routing here — it is just a page that happens to share a URL with the interceptor.
import Link from 'next/link';
import { InvoiceForm } from '@/components/invoice-form';import { Button } from '@/components/ui/button';
const NewPage = () => ( <section className="mx-auto flex w-full max-w-lg flex-col gap-6 p-6"> <header className="flex flex-col gap-1"> <h1 className="text-2xl font-semibold tracking-tight">New invoice</h1> <p className="text-sm text-muted-foreground"> Fill in the details to create an invoice. </p> </header>
<InvoiceForm />
<Button asChild variant="outline" className="self-start"> <Link href="/invoices">Cancel</Link> </Button> </section>);
export default NewPage;The Cancel link points at /invoices rather than calling router.back(), and that is on purpose: this page can be the first thing a user lands on — a shared link, a bookmark — where there is no history to go back to. A plain link to the list is the safe destination regardless of how they got here. Button asChild hands the button’s styling to the <Link> so you get a styled anchor and a real navigation in one element, which is the shadcn pattern you met when you first wired up the button component.
The dialog wrapper
Section titled “The dialog wrapper”src/components/new-invoice-dialog.tsx is the only file in this lesson that needs 'use client'. It reads the router and it handles a close event — both client concerns — so the boundary lives here and nowhere else. It wraps the provided shadcn <Dialog> and renders whatever children you hand it inside the dialog content.
'use client';
import { useRouter } from 'next/navigation';import type { ReactNode } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,} from '@/components/ui/dialog';
export const NewInvoiceDialog = ({ children }: { children: ReactNode }) => { const router = useRouter();
return ( <Dialog open onOpenChange={(open) => { if (!open) { router.back(); } }} > <DialogContent data-testid="new-invoice-dialog"> <DialogHeader> <DialogTitle>New invoice</DialogTitle> <DialogDescription> Fill in the details to create an invoice. </DialogDescription> </DialogHeader> {children} </DialogContent> </Dialog> );};The dialog is forced open with no <DialogTrigger> and no useState. The route’s existence is the open signal — if this component is mounted, the user navigated to /invoices/new, so the dialog is open by definition. There is nothing to toggle.
'use client';
import { useRouter } from 'next/navigation';import type { ReactNode } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,} from '@/components/ui/dialog';
export const NewInvoiceDialog = ({ children }: { children: ReactNode }) => { const router = useRouter();
return ( <Dialog open onOpenChange={(open) => { if (!open) { router.back(); } }} > <DialogContent data-testid="new-invoice-dialog"> <DialogHeader> <DialogTitle>New invoice</DialogTitle> <DialogDescription> Fill in the details to create an invoice. </DialogDescription> </DialogHeader> {children} </DialogContent> </Dialog> );};Closing is a navigation. Radix fires onOpenChange(false) on Escape, backdrop click, or the close button; the if (!open) guard turns only the close into a router.back(), popping the /invoices/new history entry. Without the guard you would also navigate on open, which makes no sense here.
The two things that look unusual at a glance are exactly the two that make the pattern work. There is no <DialogTrigger> — the trigger is the navigation itself, not a click handler — and open is hardcoded with no state behind it. The dialog is open because the route is matched; that is the whole idea.
One detail you are leaning on without seeing it: <DialogContent> portals its markup to the end of <body>, outside this component’s place in the tree. That is what lets the modal sit above the entire list regardless of where it is nested, escaping any ancestor that clips overflow or pins a stacking context — the trap you took apart in Stacking context and z-index.
The intercepting page
Section titled “The intercepting page”src/app/invoices/(.)new/page.tsx is the interceptor. The (.) prefix on the folder is the signal to the App Router: catch soft navigations to a new segment at this same level and render this instead. It needs to do almost nothing — compose the dialog around the form — so it stays a thin Server Component with no 'use client' of its own. The client boundary is already paid for, once, inside NewInvoiceDialog.
import { InvoiceForm } from '@/components/invoice-form';import { NewInvoiceDialog } from '@/components/new-invoice-dialog';
const InterceptedNewPage = () => ( <NewInvoiceDialog> <InvoiceForm /> </NewInvoiceDialog>);
export default InterceptedNewPage;Notice the same <InvoiceForm /> appears in both the twin and the interceptor. That is the payoff of keeping the form a pure render-only component: one form, presented two ways, with zero duplication of the form itself. The only difference between the two routes is the frame around it — a full-page <section> versus a <Dialog>.
That is the whole feature. Three short files, and every behavior in the brief falls out of the shape rather than out of extra code. Requirements 3 and 4 — refresh and Cmd+click rendering the full page — are not things you wrote handlers for; they are what the twin does by being there. The browser asks the server for /invoices/new, the interceptor does not fire because there was no soft navigation, and new/page.tsx answers. The twin is the entire implementation of “falls back to the full page.”
Official reference for the (.) (..) convention and the soft- vs hard-navigation behavior you rely on here.
The @modal slot variant — the heavier shape this lesson deliberately skips, worth seeing once.
The Dialog primitive behind NewInvoiceDialog, including its onOpenChange and portal behavior.
Moment of truth
Section titled “Moment of truth”Run the lesson’s test suite:
pnpm test:lesson 3A passing run reports all six tests green across the three behaviors the suite checks — the interceptor composing the form inside an open dialog, the twin rendering the full page without the dialog chrome, and closing the modal calling router.back() exactly once:
✓ tests/lessons/Lesson 3.test.ts (6) ✓ the intercepting route opens the New invoice form as a modal (2) ✓ a direct visit renders the full-page form, not the modal (2) ✓ closing the modal navigates back and leaves history clean (2)
Test Files 1 passed (1) Tests 6 passed (6)Then run the structural gate — Biome, type generation, tsc, and a production build, the same checks CI runs on every PR:
pnpm verifyThe tests stub the framework router and walk the rendered element tree, which is enough to prove the composition but cannot exercise the browser. Two requirements are inherently browser-and-multi-tab behavior, so confirm them by hand. Start the dev server with pnpm dev, open /invoices, and tick each one off:
/invoices opens the modal with the list visible underneath, URL at /invoices/new./invoices/new into a fresh tab renders the full page, no list.Cmd+click (or Ctrl+click) on “New invoice” opens the full page in a new tab./invoices and the back button does not bounce you back to /invoices/new.