
Admin toolbars that change per route? Skip the Context boilerplate; parallel routes exist.
I wanted to build one of those slick admin toolbars you see on Vercel’s dashboard. You know the type; it floats at the top of the page, shows “Edit” when you’re viewing content, maybe a “New post” button on the homepage. Different actions for different pages, but always there when you’re logged in.
My first instinct was to reach for React Context. Create an AdminToolbarProvider, wrap the app, then have each page call something like setToolbarContent(<EditButton />) on mount. Classic React patterns, right?
It works, but it’s messy. You end up with useEffect hooks scattered across page components, cleanup logic to reset the toolbar on unmount, and the whole thing feels like you’re fighting the framework. Client-side state management for what is essentially a routing problem.
Turns out Next.js has a feature that fits this use case perfectly: parallel routes.
Parallel routes let you render multiple page components simultaneously in the same layout. Instead of stuffing toolbar logic into each page, you create a separate “slot” that renders different content based on the current route.
Think of it like having two page.tsx files for the same URL; one for the main content, one for the toolbar. Next.js handles the matching for you.
Here’s how it looks in practice:
src/app/(main)/
├── @toolbar/
│ ├── default.tsx # Fallback for unmatched routes
│ ├── page.tsx # Homepage toolbar
│ └── journal/
│ └── [slug]/
│ └── page.tsx # Article-specific toolbar
├── layout.tsx # Receives {toolbar} slot
└── journal/
└── [slug]/
└── page.tsx # Article pageThe @toolbar folder is the magic bit. That @ prefix tells Next.js this is a parallel route slot, not a regular route segment.
The layout receives your slot as a prop, alongside children:
// src/app/(main)/layout.tsx
export default function Layout({
children,
toolbar,
}: {
children: React.ReactNode;
toolbar: React.ReactNode;
}) {
return (
<html>
<body>
<AdminToolbar>{toolbar}</AdminToolbar>
{children}
</body>
</html>
);
}The wrapper component handles auth, so you only write that logic once:
// src/components/admin-toolbar.tsx
export async function AdminToolbar({
children,
}: {
children: React.ReactNode;
}) {
const session = await getSession();
if (!session.valid) return null;
return <div className="...toolbar styles...">{children}</div>;
}Then each route gets its own toolbar content:
// src/app/(main)/@toolbar/journal/[slug]/page.tsx
export default async function ArticleToolbar({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const article = await getArticleBySlug(slug);
if (!article) notFound();
return (
<>
{article.status !== "published" && <PreviewLabel />}
<EditLink href={`/admin/posts/${article.id}`} />
</>
);
}No Context providers, no client-side state, no cleanup effects. Just file-based routing doing what it does best.
You need a default.tsx file. Without it, routes that don’t have explicit toolbar content will break. This file is your fallback; it can return null or some generic toolbar content.
Directory structure must mirror your page routes exactly. For /journal/[slug] to get its own toolbar, you need @toolbar/journal/[slug]/page.tsx. Miss a segment and it falls back to default.tsx.
Data fetching is deduplicated automatically. Both the toolbar and the page can call getArticleBySlug(slug) without worrying about duplicate database queries. React’s request memoization handles this within the same render pass.
Want toolbar content for /work? Create @toolbar/work/page.tsx and export your component. That’s it. Routes without explicit toolbar content fall back to default.tsx.
The best part? This scales without accumulating complexity. Each route’s toolbar logic lives next to that route’s page logic, completely isolated from everything else.
I know it sounds like the Next.js team has come full circle, because we used to build server-rendered pages with templating engines like Nunjucks and Liquid in ways similar to this.
Leveraging SSR in React like we did in the olden days is actually a power move. The lines between old and new are getting blurred, and I’m all for it.