Header Actions from Routes
The app header has three slots. AppLayout.HeaderStart holds
the sidebar trigger, AppLayout.HeaderContent holds the
breadcrumb trail, and AppLayout.HeaderActions holds the
page's actions. The shell renders that header once, above its <Outlet />, so a leaf route cannot reach the
third slot from its own JSX. Which actions render is still that route's decision. This recipe makes the
decision a declaration on the route module, the same way
Breadcrumbs from Routes declares the trail: the route exports a
handle, and the shell reads it out of useMatches().
Try it in the live demo, directly above the file that renders it.
Why not a context provider
The tempting shape is a provider in the shell and a usePageHeaderActions(<Actions />) hook a page calls
from an effect. Avoid it. It shares three of the
four costs the breadcrumbs recipe lists (a
fixed depth does not apply), and adds one more:
- A wrong first paint. The actions arrive in an effect, so the first frame has none. Server-rendered HTML has none at all.
- Clobbering on navigation. The outgoing page's cleanup and the incoming page's push race, so which actions survive depends on effect order.
- Two sources of truth. The route tree already says which page is active; the provider says it again.
- Actions that outlive their page. The push depends on the page having mounted. A page that suspends, errors, or renders behind a gate leaves the previous page's actions in the header.
Deriving from useMatches() has none of these. It is correct on the server, correct on the first frame, has
no order to get wrong, and follows the route tree exactly.
Anatomy
Four pieces, each with one job:
handle.headerActionson a route module: the element to render, ornullto render none.findHeaderActions: picks the deepest match that declares one. Pure and router-agnostic, so a test asserts it over a fixture.RouteHeaderActions: the one component that callsuseMatches(). The hook stays at the edge, so the demo runs the same function with no router.PageActions: renders a list of actions as icon buttons at desktop widths and as one menu below the mobile breakpoint. A route composes its own element instead whenever an action is not a plain button.
All four ship in one file, which stays in your app because it owns the two decisions mantle cannot make: which router you use, and how your product draws an action.
The handle
handle is a static export of the route module, so it is never serialized and it is available on the server.
Its shape here is one property:
Three rules decide what renders:
- The deepest match that declares
headerActionswins. A detail route replaces its list route's actions. - A route that declares nothing inherits. A tab route under a detail route keeps the detail's actions on every tab, with no per-tab wiring.
nullopts out. A child route under an ancestor that declares actions renders none.
Declare an element, not a component type: headerActions: <PlaygroundActions />. The shell renders it
where the slot is, so hooks inside PlaygroundActions run normally. The satisfies clause makes a string or
a component type a compile error, and hasHeaderActions treats one as no declaration at runtime.
Actions that need state
The element renders in the shell, not inside the page, so it cannot read the page component's useState.
That constraint keeps the first paint right. Keep the state where the server can see it: the URL, the
route's loader data, or the query cache the page reads. Router hooks work in the
shell, because the element renders inside the router. useParams() returns the leaf's params from a layout,
useSearchParams() reads and writes the URL, and useRouteLoaderData(routeId) reads any matched route's
data. If the actions must read the page component's own state, use the canonical shell instead:
One header per page.
The page reads the same search param to open its panel. One value, two readers, and neither pushes anything to the other.
Collapsing below the mobile breakpoint
PageActions renders two controls and lets CSS pick one: an icon button per action with a tooltip from the
md breakpoint up, and one DotsThree icon button below it that opens a
DropdownMenu of the same actions. Both controls are in the HTML, so the
server paints the right one and hydration swaps nothing. The menu's items mount when the menu opens. A
useIsBelowBreakpoint branch would render the desktop buttons on the server and swap them after hydration
on a phone.
The tooltips need a TooltipProvider ancestor, so mount one
at the app root. Tooltip.Root throws without it.
Two rules keep the row honest:
- Three actions at most. The header is one row, and the trail beside it needs the width more than a fourth button does. Move the rest into the page.
- Match the sidebar's breakpoint. The file uses
mdbecause the docs demos narrowmobileBreakpointtomd. ngrok's dashboards keep the sidebar'slgdefault, so changemd:contentsandmd:hiddentogether.
When an action is not a plain button (a Dialog trigger, a router Link, a
toggle with its own state), compose the element yourself instead of a PageActions list. The slot takes any
JSX.
Where it goes in the shell
Render it once, in AppLayout.HeaderActions. The three slots
carry the flex, so no call site sets flex-1, min-w-0, shrink-0, or ml-auto itself:
AppLayout.HeaderActions is ml-auto shrink-0: it takes only the width its actions need, and it stays at the
row's end even when the trail is empty. AppLayout.HeaderContent is min-w-0 flex-1, so a long trail scrolls
inside it instead of pushing the actions off the card.
Server rendering
Everything here runs on the server unchanged:
useMatches()is SSR-safe. The matched chain is what the server router already resolved.handleis available on the server. Route modules load there too, so the element is in the first render.findHeaderActionsis pure. Same matches, same element, both sides.- Both controls ship in the HTML. The collapse is CSS, so a phone gets the menu's trigger in the first byte, and the items mount when it opens.
- URL state renders right the first time. A label that reads a search param is correct on the server because the server has the URL.
An action whose label depends on client-only state (localStorage, window.matchMedia) reads it after
hydration inside its own component, never in the handle.
The file
The one file to copy. RouteHeaderActions is the only piece that touches the router. findHeaderActions
and PageActions are pure, so a fixture tests them and the demo below uses them unmodified.
Pick a URL. The actions follow the deepest route that declares any:
Testing it
findHeaderActions is a plain function over an array of matches, so the branches that matter (deepest
wins, a child inherits, null opts out) need no router at all.
createRoutesStub forwards handle from stub routes
onto the matches, which covers the useMatches() edge end to end. This is the file's test, verbatim. It runs
in this repo: