routing-navigation-review
Reviews route-tree structure, loader/action placement, code-splitting boundaries, and navigation-blocking/focus-management behavior in React Router and Next.js applications for correctness, server-side security enforcement, and accessibility conformance on route transitions.
Install
npx skills add https://github.com/VincentChuWaiChow/vanguard-frontier-agentic/tree/master/skills/frontend/routing-navigation-review
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install vincentchuwaichow-vanguard-frontier-agentic@llmmart
git clone https://github.com/VincentChuWaiChow/vanguard-frontier-agentic.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole vincentchuwaichow/vanguard-frontier-agentic collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Routing & Navigation Review
Purpose
Review a frontend application's route tree — path/layout nesting, loader/action data-fetching placement, code-splitting boundaries, and navigation-blocking/focus-management behavior — without re-litigating what data a loader fetches from the backend contract (that is api-integration-contract-review) or SSR streaming/hydration mechanics at a route's data boundary (that is ssr-hydration-streaming-diagnosis) in every response. This skill exists because route-level defects hide in three distinct, easily-conflated places: a route that is "protected" only by hiding a nav link or redirecting client-side (an authorization bypass reachable by typing the URL directly), a code-split boundary that accidentally serializes loader→component→action instead of loading them in parallel, and a route transition that silently drops keyboard focus with no status announcement (a WCAG 2.4.3 / 4.1.3 failure that is invisible unless you trace it deliberately).
When to use
Use this skill when the user asks to:
- review a new route or a route-tree restructuring before merge,
- audit whether routes described as "protected" are actually enforced server-side, not just hidden client-side,
- investigate broken deep-links, lost filter/pagination/tab state on refresh, or "back button doesn't work right" bugs,
- respond to an accessibility audit finding of lost focus or missing status announcements on navigation,
- review code-splitting/lazy-loading changes to a route tree for waterfall regressions.
Do not use this skill for:
- reviewing what data a loader fetches from the backend contract, response shape, or error handling — use
api-integration-contract-reviewinstead, - SSR streaming/hydration mechanics at a route's data boundary (Suspense boundaries, streaming HTML, hydration mismatches) — use
ssr-hydration-streaming-diagnosisinstead, - component-internal state with no route/URL involvement.
Context7 Documentation Protocol
- Resolve library IDs before citing any framework-specific claim:
/remix-run/react-routerfor React Router,/vercel/next.jsfor Next.js. Do not assume API shape from memory — both frameworks' routing/data APIs have changed materially across major versions (React Router v6 object-route API vs. v7 framework-mode route modules; Next.js Pages Router vs. App Router). - Before asserting server-side enforcement patterns in React Router, query
/remix-run/react-routerfor "loader authentication redirect" and confirm the current guidance: aloader(or a middleware paired with aloaderto force it to run on every client-side navigation) is the enforcement point — a component-level redirect or conditional render is not, because React Router still renders/matches the route client-side without a network round trip that a server can gate. - Before asserting server-side enforcement patterns in Next.js, query
/vercel/next.jsfor "data access layer authorization" and confirm the current guidance: official docs explicitly framemiddleware/proxy-based checks (cookie-presence checks run at the edge) as an optimistic first pass for UX/redirect purposes, and require the actual authorization check to live in a server-only Data Access Layer close to the data source (Server Component, Server Action, or Route Handler) — do not treat a middleware matcher as sufficient enforcement on its own. - Before asserting code-splitting behavior, query
/remix-run/react-routerfor "lazy route module" and confirm current guidance: the recommendedlazyroute property loads the component and itsloader/actiontogether (e.g., viaPromise.all) so they resolve in parallel — a common regression isawait-ing them in sequence instead. - Before asserting navigation-blocking behavior, query
/remix-run/react-routerfor "useBlocker" and confirm current constraints:useBlockeronly works within data routers (createBrowserRouter/framework mode) and explicitly does not intercept hard reloads or cross-origin navigations — do not present it as a universal unsaved-changes guard. - Verify the installed major version of React Router or Next.js (
package.json) before asserting version-specific route-module or App Router conventions; if the repo is on Pages Router or React Router v5/v6 classic mode, framework-mode/App Router guidance does not transfer directly. - If Context7 is unavailable, fall back to the
official_docsURLs in this skill'smetadata.jsonand label the claimdocumentation-based, unverified against current release.
Lean operating rules
- First map the full route tree (paths, layout nesting, index routes) before evaluating any individual route — a route's effective protection or focus behavior can depend on a parent layout's loader or wrapper.
- Classify every route as public or protected using the authorization model the team actually has, not assumption. A route is protected only if there is a server-side enforcement point: a React Router
loader(or middleware forced to run via a paired loader) that redirects/throws, or a Next.js Data Access Layer check inside a Server Component/Server Action/Route Handler. Hiding a nav link, a client-sideuseEffectredirect, or a component-level conditional render is UX affordance only — treat any "protected" route lacking a paired server-side enforcement point as a blocking (HIGH) security finding, not a style note. - Do not accept a Next.js
middleware/proxy auth check as sufficient enforcement by itself; official Next.js guidance frames it as an optimistic edge check. Require the corresponding Data Access Layer check to also exist, and flag a middleware-only implementation as a HIGH finding even if the middleware matcher looks correct. - When reviewing code-splitting, verify whether loader/component/action for a lazy route resolve via a parallel construct (e.g.,
Promise.all, or the framework's singlelazyproperty that loads them together) versus sequentialawaitcalls that create a waterfall — the latter is a measurable performance regression, not a style preference. - Treat any view-critical state (active filter, pagination page, selected tab, search query) that lives only in component state/memory as a defect if it should be shareable or survive a refresh — it belongs in the URL (path segment or search params), not only in memory. This is what breaks deep-links and the back button.
- Trace focus management on every route transition: identify what receives focus after navigation (a heading, the main landmark, or nothing) and whether an
aria-liveregion announces the route/status change for assistive technology. Absence of either is an accessibility (WCAG 2.4.3 Focus Order / 4.1.3 Status Messages) finding, not a nice-to-have. - For form-heavy routes, verify navigation-blocking (e.g., React Router
useBlocker) exists for unsaved changes, and verify its known limits (SPA-only; does not cover hard reloads or cross-origin navigation) are either accepted knowingly or covered by abeforeunloadhandler for the hard-reload case. - Never execute, build, or run application code as part of this review; this is a static-review skill (Read/Grep/Glob only). Do not attempt to open a browser or simulate navigation to "check" focus behavior — trace it from source (component refs,
useEffecton location change,aria-liveregions) and label the findingrepo evidencewith a caveat that runtime confirmation was not performed.
References
Load these only when needed:
- Review workflow and findings contract — use for the step-by-step review procedure, the route-tree mapping table, and the required output shape.
- Server-side enforcement patterns — load only when auditing whether a "protected" route has real server-side enforcement (React Router loader/middleware, Next.js Data Access Layer) versus client-side-only gating.
- Code-splitting and URL state — load only when reviewing lazy-loading/waterfall regressions or when view-critical state needs to move into the URL.
- Focus management and navigation blocking — load only when reviewing focus/
aria-livebehavior on route transitions or unsaved-changes navigation blocking.
Response minimum
Return, at minimum:
- a route-tree table: path, protection level with its server-side enforcement pointer (or "none found"), code-split boundary, and focus-management target,
- ranked findings with file:line evidence,
- evidence level per finding (
repo evidence,documentation-based, orinference), - verdict (approve / approve-with-notes / block),
- open questions or scope the review could not cover (e.g., runtime focus behavior not simulated, transitive import not read).
Files (vanguard-frontier-agentic)
-
references
-
code-splitting-and-url-state.md 6.5 KB
# Code-Splitting and URL State Use this reference only when reviewing lazy-loading/waterfall regressions in a route tree, or when view-critical state needs to move into the URL. Load it during steps 4–5 of the review workflow. ## What people get wrong The naive assumption is: > "I added `lazy: () => import('./route')`, so the route is code-split correctly." That covers the component, but a route module also has a `loader` and possibly an `action`. If those are fetched with separate sequential `await` statements instead of a combined parallel construct, the route now pays for two or three round trips (component chunk, then loader chunk, then action chunk) instead of one — a self-inflicted waterfall that looks identical in the diff ("we added lazy loading!") but regresses time-to-interactive. A second common assumption: > "The filter/tab/page state is in `useState`, so it works fine — I can see it update." It "works" for the current session in the current tab. It does not survive a refresh, cannot be shared as a link, and breaks the browser back/forward button's expected behavior — all silent failures that only surface as user complaints ("I sent my coworker this link and it didn't have my filters"). ## Officially grounded code-splitting shape (React Router) - The recommended `lazy` route property loads the component and its data functions together. The documented pattern uses `Promise.all` (or the single combined `lazy` module import) specifically so the component and `loader` resolve **in parallel**, not one after another: ```tsx { path: "/app", lazy: async () => { // load component and loader in parallel before rendering const [Component, loader] = await Promise.all([ import("./app"), import("./app-loader"), ]); return { Component, loader }; }, } ``` - The simpler single-module `lazy: () => import("./about")` form (importing one module that exports `loader`/`action`/`Component` together) achieves the same parallelism implicitly, because it's one dynamic import resolving one module graph. - A regression to watch for: someone splits `loader`, `action`, and `Component` into three separate `lazy.loader`, `lazy.action`, `lazy.Component` async functions (a valid, documented granular form) but then has one `await` the result of another inside its own function body instead of letting the router resolve them independently — that reintroduces a sequential dependency the granular form was supposed to avoid. ## Officially grounded code-splitting shape (Next.js) - Next.js App Router code-splits by route segment automatically; the equivalent regression to check for is a route's `page.tsx` or `layout.tsx` performing sequential `await` calls for independent data instead of using parallel data fetching (e.g., initiating multiple `fetch`/DB calls before awaiting any of them, or using `Promise.all`), and a missing `loading.tsx` for a route whose data-fetching is inherently slow — the documented pattern is to add `loading.tsx` so navigation feels instant while server rendering completes, rather than leaving the user on a blank screen. ## Design rules: code-splitting 1. **Component + loader (+ action) should resolve in parallel for a given lazy route**, not sequentially. Flag `await import(a); await import(b)` sequences that could be `Promise.all([import(a), import(b)])`. 2. **Check for a `loading.tsx`/equivalent fallback** on any route segment whose data-fetching is non-trivial, so the user gets immediate navigation feedback rather than a frozen UI — absence is a MEDIUM UX/performance finding, not a hard block. 3. **Don't flag intentional sequential dependencies** — if a `loader` genuinely needs data only available after a component-level decision (rare, and usually a smell in its own right), sequential loading may be correct; verify the dependency is real before flagging. ## Officially grounded URL-state shape - View-affecting UI state that should be shareable, bookmarkable, or survive a refresh belongs in the URL: a dynamic path segment for identity-like state (`/blog/[slug]`), and search params for filter/pagination/sort/tab state (`?page=2&sort=recent&tab=comments`). - React Router and Next.js both provide first-class hooks/utilities for reading and writing search params tied to navigation (`useSearchParams` in both, with framework-specific write patterns) — the presence of one of these being used for a given piece of state is a strong positive signal; the absence of any URL-touching code for filter/tab/pagination UI, combined with a `useState` holding that same data, is the defect pattern to search for. ## Design rules: URL state 1. **Classify each piece of route-level UI state** as either (a) session-only/ephemeral (a dropdown's open/closed state — fine in `useState`) or (b) view-critical/shareable (a filter, page number, active tab, search query, sort order — should be in the URL). Do not flag category (a). 2. **For category (b) state found only in `useState`/component state with no corresponding search-param or path-segment representation**, flag it — severity depends on whether the route is meant to be shareable or bookmarkable (a report/dashboard/search-results route is usually meant to be; an internal wizard step sometimes isn't — verify intent rather than assuming). 3. **Verify state survives a hard refresh conceptually**: if the only place a value lives is a `useState` initializer with no URL/`localStorage`/server-state backing, a refresh loses it — that's the concrete, testable definition of the defect, more useful in a finding than "should be in the URL" alone. ## Adversarial checklist - Does the lazy route's component and loader (and action, if present) resolve via a parallel construct, or did you find a sequential `await` chain? - Is there a `loading.tsx`/fallback for routes whose data-fetching is non-trivial? - For every filter/pagination/tab/search UI element on a route, did you check whether its state is represented in the URL, or did you only check the ones a bug report already named? - If state is in the URL, is it read back out on load (so a shared link actually reproduces the view), not just written on change? ## Safe verification targets - Grep for `lazy:` route properties and inspect whether their resolution uses `Promise.all` / a single combined import, or separate `await` statements. - Grep for `useState` hooks adjacent to filter/tab/pagination UI and cross-reference against `useSearchParams`/`URLSearchParams` usage in the same file. - Check for `loading.tsx` (Next.js) or an equivalent pending/fallback UI sibling to slow route segments. -
focus-and-navigation-blocking.md 7.4 KB
# Focus Management and Navigation Blocking Use this reference only when reviewing focus/`aria-live` behavior on route transitions, or unsaved-changes navigation blocking. Load it during steps 6–7 of the review workflow. ## What people get wrong The naive assumption is: > "React Router / Next.js handles focus on navigation automatically, like a real page load would." It does not, by default, in either framework. A client-side route transition in an SPA swaps DOM content without the browser's native full-page-navigation behavior (which resets focus to the document body and lets screen readers announce the new page title). If nothing in the application explicitly moves focus and announces the change, a screen reader user who navigates via a link ends up with focus still anchored to the link they just activated (now possibly removed from the DOM, or pointing at stale content) with no announcement that anything changed — a silent failure sighted users never notice because they can see the visual change. A second naive assumption: > "I added a confirm dialog with `window.confirm` in a `beforeunload` handler, so unsaved changes are protected everywhere." `beforeunload` only fires for hard navigations (reload, closing the tab, typing a new URL, following a link with a full page load). It does **not** fire for client-side SPA navigations triggered by the router — those need a router-level mechanism (e.g., React Router's `useBlocker`), which in turn has the opposite limitation: it does not cover hard reloads or cross-origin navigation. Neither mechanism alone is complete; a form-heavy route that cares about both needs both. ## Officially grounded shape: focus management - Neither React Router nor Next.js ships automatic focus-to-heading-on-navigation behavior as a default, framework-level guarantee for arbitrary route trees. The established accessible-SPA pattern (consistent with the WAI-ARIA Authoring Practices Guide's page/route-change guidance) is: on route change, move focus to a stable, predictable target — commonly the page's `<h1>` (made programmatically focusable with `tabIndex={-1}`) or a `main` landmark — and pair it with an `aria-live="polite"` (or `assertive`, for urgent changes) region that announces the new page/section so screen reader users get an audible cue equivalent to what a full page load provides natively. - Look for this pattern implemented via a `useEffect` keyed on the route's location/pathname (React Router: `useLocation`; Next.js: `usePathname`) that calls `.focus()` on a ref, and a live region whose text content updates on the same trigger. ## Design rules: focus management 1. **Every route transition needs a defined focus target.** Absence of any focus-management code (no ref, no `.focus()` call, no live-region update keyed to navigation) is a HIGH finding (WCAG 2.4.3 Focus Order) — this is not cosmetic, it breaks the core navigation model for keyboard/screen-reader users. 2. **The focus target should be stable and meaningful**, not an arbitrary or randomly-remaining-focused element. Flag a focus target that lands on something removed/re-rendered on every transition (causing focus loss anyway) or on an element with no semantic relationship to "the new page." 3. **An `aria-live` announcement (or equivalent, e.g., a visually-hidden status element updated on navigation) should accompany the focus move** for the change to be announced, not just focusable — flag a repo that moves focus but never updates any live-region/status text as a WCAG 4.1.3 (Status Messages) gap, distinct from the 2.4.3 focus-order gap. 4. **Do not assume a component library "handles this."** Verify by tracing the actual `useEffect`/ref/live-region code for the route(s) in scope; a UI kit's individual components (buttons, dialogs) having internal focus management does not imply the app's top-level router transitions do too. ## Officially grounded shape: navigation blocking - React Router's documented mechanism for blocking in-SPA navigation is `useBlocker(shouldBlock)`, which returns a `Blocker` with `state` (`unblocked` | `blocked` | `proceeding`), and `proceed()`/`reset()` methods to let the confirmation UI resolve the block. It requires a data router (`createBrowserRouter` / framework mode) — it does not exist as a hook on the older `<BrowserRouter>` non-data-router API. - `useBlocker` explicitly does **not** intercept hard reloads, tab closes, or cross-origin navigations — those require a separate `beforeunload` handler. The two mechanisms are complementary, not interchangeable. - Next.js does not ship a first-party equivalent to `useBlocker` for App Router client-side transitions as a stable, documented primitive in the same way; verify current docs/Context7 before asserting a specific Next.js API exists, and if none is confirmed, flag the absence of any unsaved-changes protection as a finding rather than assuming a framework default covers it. ## Design rules: navigation blocking 1. **Form-heavy routes need unsaved-changes protection for SPA navigation**, hard reload/tab-close, or both, depending on what's realistic for the app. Absence entirely is a MEDIUM (escalate to HIGH for long/high-stakes forms) data-loss-risk finding. 2. **If only `beforeunload` exists**, flag that in-app SPA navigation (clicking another nav link) is still unprotected — this is the more common real-world path a user takes to accidentally lose data, more so than closing the tab. 3. **If only `useBlocker`/equivalent exists**, flag that hard reload/tab-close/cross-origin navigation is still unprotected, and confirm whether that gap is acceptable for the route's risk profile or needs a `beforeunload` handler added alongside it. 4. **Verify the block condition is based on actual dirty-state tracking** (form value changed from initial), not a coarse "is this route a form" heuristic that blocks navigation even with no changes made — that's a usability regression in the opposite direction (blocking users who made no edits). ## Adversarial checklist - For each route transition in scope, what element receives focus — did you trace it from an actual ref/`.focus()` call, or assume "it probably works"? - Is there an `aria-live` region (or equivalent) that updates on the same transition, or does focus move silently? - For each form-heavy route, does unsaved-changes protection exist at all? If so, does it cover SPA navigation, hard reload, or both — and is the gap (if any) acceptable? - Is the "unsaved changes" condition tied to real dirty-state, or would it block navigation even when nothing changed? ## Safe verification targets - Grep for `useLocation`/`usePathname` combined with a `useEffect` and a `.focus()` call or ref assignment. - Grep for `aria-live` attributes and confirm their content updates on route change (not just present once, statically, with no dynamic text). - Grep for `useBlocker`, `beforeunload`, or equivalent on routes containing `<form>` elements or form-library state (e.g., react-hook-form's `formState.isDirty`). ## When to push back Push back if the user says: - "the browser handles focus, we don't need to do anything" (true for hard navigations, false for SPA route transitions), - "we'll add focus management later, ship the route now" (this is a compliance gap the moment the route ships, not a deferred nice-to-have), - "`beforeunload` covers it" for a route whose primary navigation-away path is in-app link clicks, not tab closes. Those defer a defect users with assistive technology hit immediately, not an edge case. -
server-enforcement-patterns.md 7.1 KB
# Server-Side Enforcement Patterns Use this reference only when auditing whether a "protected" route has real server-side enforcement versus client-side-only gating. Load it during step 3 of the review workflow. > Version note: React Router's `middleware` API and Next.js's Data Access Layer guidance are both relatively recent formalizations of patterns that existed informally before. Verify the installed major version before asserting these exact APIs exist; older codebases may implement equivalent checks through custom loader wrappers or `getServerSideProps`-era patterns. ## What people get wrong The common bad assumption is: > "The route component checks `if (!user) redirect('/login')`, so it's protected." That check runs **inside the client-rendered tree**, after the route has already matched and after any component code (and often any data-fetching hooks) above the redirect has already begun executing. It also does nothing to stop a direct request for the route's data (an API call the component makes, or in Next.js, a Server Component that fetches before the redirect logic runs if the check is misplaced). A client-side redirect is a UX nicety for already-authenticated-but-wrong-state users; it is not an authorization boundary. ## Officially grounded enforcement shape ### React Router: loader (with middleware for forced execution) - The authoritative enforcement point is a `loader` that throws a `redirect()` (or throws a 401/403 response) when the request is unauthenticated/unauthorized. `redirect` thrown from a loader is the documented pattern for gating protected routes. - If middleware is used for cross-cutting auth logic, official React Router guidance notes that middleware alone does not run on every client-side navigation unless the route also has a `loader` — pairing an (even trivial, no-op) `loader` with the middleware forces the middleware to execute on every client-side transition into that route. A middleware with no paired loader is not a reliable enforcement point for client-side (SPA) navigations. - A route whose only "protection" is a check inside its `Component`/element (not its `loader`) is a client-side-only gate — flag it. Enforcement pointer to look for: ```tsx export async function loader({ request }: Route.LoaderArgs) { if (!isLoggedIn(request)) { throw redirect("/login"); } // ... fetch protected data only after the check } ``` ### Next.js: Data Access Layer (not middleware alone) - Official Next.js guidance frames `middleware`/proxy-based auth checks as an **optimistic** check: it typically reads a session cookie's presence (not full validation) at the edge, primarily to redirect for UX purposes before a page even starts rendering. - The authoritative check belongs in a **Data Access Layer**: a server-only module (marked with `import 'server-only'` or equivalent) that every Server Component, Server Action, and Route Handler touching sensitive data calls into. This layer performs the real `auth()`/session validation, an authorization check (does this user own/have access to this specific resource — guarding against IDOR), and returns only a minimal safe DTO. - A route protected only by middleware, with no corresponding Data Access Layer check in the Server Component/Action/Route Handler that actually serves the data, is enforcement-incomplete — flag it as HIGH even if the middleware matcher is correctly scoped. - Server Actions in particular must re-check authentication and authorization inside the action itself (not rely on the fact that the triggering page was gated) — official production-checklist guidance calls this out explicitly, including checking resource ownership (e.g., `post.authorId !== session.user.id`) to prevent IDOR, not just "is logged in." Enforcement pointer to look for: ```ts import 'server-only' import { auth } from '@/lib/auth' import { db } from '@/lib/db' export async function deletePost(postId: string) { const session = await auth() if (!session?.user) throw new Error('Unauthorized') const post = await db.post.findUnique({ where: { id: postId } }) if (post.authorId !== session.user.id) throw new Error('Forbidden') await db.post.delete({ where: { id: postId } }) } ``` ## Non-negotiable design rules 1. **Every protected route needs a citable server-side enforcement pointer.** "The nav link is hidden" or "the component redirects" is not a pointer; a `loader` throw, a middleware-with-paired-loader, or a Data Access Layer call is. 2. **Do not treat Next.js middleware as sufficient by itself.** It is a UX-redirect layer over an optimistic check. Require the Data Access Layer check too, and flag the gap if it's missing even when middleware looks airtight. 3. **Check resource-level authorization, not just authentication.** "Is logged in" is necessary but not sufficient for routes/actions scoped to a specific resource (e.g., `/orders/:id`, `deletePost(postId)`) — verify an ownership/permission check exists, or the finding is an IDOR risk, not just a missing-auth risk. 4. **Server Actions and Route Handlers must self-check.** Do not assume a Server Action inherits protection from the page that renders the form that triggers it — the official guidance requires the action to re-verify. 5. **BFF/API-layer enforcement counts too.** If the frontend calls a backend-for-frontend or API layer that itself enforces authorization independent of the loader/DAL, that also satisfies the requirement — but verify it, don't assume "the API probably checks this." ## High-risk assumptions to kill - "It's not in the nav, so users can't get there." - "The middleware matcher covers this path, so it's protected." (Next.js — middleware alone is optimistic.) - "The loader fetches the data, so of course it checks auth first." (Verify the check actually runs before the fetch, not after or not at all.) - "The Server Action is only called from the protected page's form, so it inherits protection." (It does not; actions are independently callable.) - "Checking `session?.user` is enough for a resource-scoped route." (Also check resource ownership/permission.) ## Safe verification targets - Grep every route's `loader`/Server Component/Server Action/Route Handler for an auth check that occurs before any protected data fetch or mutation, and confirm it throws/redirects rather than just setting a flag a component might ignore. - For Next.js, grep for `middleware.ts`/`proxy.ts` and confirm whether a corresponding Data Access Layer (`server-only` import) exists and is actually called by the routes the middleware matches. - For React Router, confirm any auth `middleware` has a paired `loader` on the same route (or an ancestor layout route) to force execution on client-side navigations. ## When to push back Push back if the user asks to: - ship a route as "protected" based solely on hiding a nav item or a component-level redirect, - rely on a Next.js middleware matcher alone as the complete authorization boundary, - skip resource-ownership checks because "the user is already logged in," - skip re-checking auth inside a Server Action because "the calling page is already gated." Those are not shortcuts. They are authorization bypasses waiting for a direct URL, a replayed request, or a changed session state. -
workflow-and-output.md 7.5 KB
# Review Workflow and Findings Contract Use this reference for the step-by-step review procedure, the route-tree mapping table, and the required output shape for a routing/navigation review. > Version note: React Router's route-module API (`loader`, `action`, `lazy`, `middleware`) and Next.js's App Router conventions (middleware/proxy, parallel/intercepting routes, `loading.tsx`) are version-sensitive. Verify the installed major version (`package.json`) before asserting exact API shape; React Router v5/v6-classic and Next.js Pages Router use materially different patterns than the current framework-mode/App Router guidance this skill is grounded in. ## What people get wrong The naive assumption is: > "The route isn't in the nav menu / it redirects to `/login` in the component, so it's protected." That is incomplete in two distinct ways: 1. A client-side redirect or conditional render still requires the route to match and the component tree to mount before the redirect fires. Nothing stops a user (or a script) from requesting the route's data directly, reading the JS bundle for that route, or racing the redirect. The only real gate is a check that runs **before** the sensitive work happens, on the server: a React Router `loader` that throws/redirects, or a Next.js Server Component/Server Action/Route Handler check. 2. In Next.js specifically, teams often stop at a `middleware`/proxy check and call the route "protected." Official Next.js guidance is explicit that middleware-based checks are an *optimistic* pass (cookie presence, not full session validation) intended for UX redirects — the authoritative authorization check belongs in a server-only Data Access Layer close to the data source. A middleware-only implementation is not done; it is half-done. ## Step-by-step workflow 1. **Map the route tree.** Enumerate every route: path, layout nesting/parent chain, and whether it is an index route. Note which framework/router is in play (React Router framework mode vs. classic `<Routes>`; Next.js App Router vs. Pages Router) — the enforcement and code-splitting patterns differ by framework and by mode. 2. **Classify protection level per route.** For each route, determine whether it is meant to be public or protected using the team's actual authorization model (ask if undocumented; do not assume). Do not infer protection level from the route's name or nav visibility. 3. **Locate the server-side enforcement point for every protected route.** See `references/server-enforcement-patterns.md` for the exact patterns to look for in React Router (`loader`/middleware-with-loader) and Next.js (Data Access Layer). If none exists, this is the finding — do not continue to treat the route as protected for the rest of the review. 4. **Inspect code-splitting boundaries.** For each lazily-loaded route, determine whether its component, loader, and action resolve via a parallel construct or a sequential `await` chain. See `references/code-splitting-and-url-state.md`. 5. **Check URL-reconstructable state.** For each route with filters, pagination, tabs, or other view-affecting UI state, determine whether that state is represented in the URL (path segment or search params) or only in component/memory state. See `references/code-splitting-and-url-state.md`. 6. **Trace focus management and status announcements.** For each route transition in scope, determine what receives focus after navigation and whether an `aria-live` region (or equivalent) announces the change. See `references/focus-and-navigation-blocking.md`. 7. **Check navigation-blocking on form-heavy routes.** Determine whether unsaved-changes protection exists and whether its known SPA-only limits are accounted for. See `references/focus-and-navigation-blocking.md`. 8. **Rank and report findings** per the output shape below. ## Route-tree mapping table Produce this table before listing findings: | Path | Protection level | Enforcement pointer (file:line, or "none found") | Code-split boundary | Focus target on transition | |---|---|---|---|---| | `/dashboard` | protected | `app/dashboard/page.tsx` calls `getDashboardData()` DAL check, `lib/dal.ts:12` | route-level `lazy` | `<h1>` ref-focused in `layout.tsx:8` | | `/login` | public | n/a | eager | n/a | ## Decision tree - Protected route has no server-side enforcement point (client-only redirect/hide, or Next.js middleware check with no matching Data Access Layer check) → **HIGH / block**. State exactly what exists (if anything) and why it is insufficient. - Protected route's only enforcement is a Next.js `middleware`/proxy check with no corresponding server-only check in the Server Component/Action/Route Handler that touches the data → **HIGH**. Middleware-only is not equivalent to a Data Access Layer check per current Next.js guidance. - Lazy route's component/loader/action are `await`-ed sequentially instead of via `Promise.all` or the framework's combined `lazy` loader → **MEDIUM**, waterfall regression; cite the measurable extra round trip. - View-critical state (filter/page/tab) exists only in component state with no URL representation → **MEDIUM/HIGH depending on user impact** (HIGH if the route is meant to be shareable/bookmarkable, e.g., a saved-search or report link). - Route transition has no defined focus target and no `aria-live` announcement → **HIGH** (WCAG 2.4.3 / 4.1.3), even if the app "looks fine" visually — this is an assistive-technology-only defect class. - Form-heavy route has no navigation-blocking for unsaved changes → **MEDIUM** data-loss risk; escalate to HIGH if the form represents non-trivial user input (long forms, financial/legal data entry). - `useBlocker` (or equivalent) is present but the route also needs hard-reload/tab-close protection and has none → **LOW/MEDIUM** note the gap; `useBlocker` explicitly does not cover hard reloads or cross-origin navigation. ## Adversarial checklist Before closing a review with no HIGH findings, confirm: - For every route you called "protected," did you find and cite the actual server-side enforcement pointer, or did you accept a nav-hiding/component-redirect as sufficient? - For every Next.js protected route, did you check for a Data Access Layer check in addition to (or instead of) middleware, per current official guidance? - Did you verify lazy-loaded routes resolve component/loader/action in parallel, not just that `lazy` is used at all? - Did you check every route with filter/pagination/tab UI for URL representation, not just the ones an issue report already flagged? - Did you trace focus behavior from source (refs, `useEffect` on location change, `aria-live` regions) rather than assuming a framework "handles it automatically"? - If you found zero HIGH findings, is that because none exist, or because you didn't trace enforcement/focus/URL-state far enough? ## Output shape Every review response must include: 1. **Scope** — routes and files reviewed, framework/router and major version noted. 2. **Route-tree mapping table** — as specified above. 3. **Findings** — ranked HIGH → MEDIUM → LOW, each with `file:line`, the category (enforcement gap / waterfall / URL-state / focus-management / navigation-blocking), and a concrete fix sketch. 4. **Evidence level** per finding: `repo evidence`, `documentation-based`, or `inference`. 5. **Verdict** — approve / approve-with-notes / block. Any protected route with no server-side enforcement point is an automatic block. 6. **Open questions** — anything the review could not verify (unconfirmed framework version, runtime focus behavior not simulated, ambiguous authorization model).
-
-
metadata.json 1.2 KB
{ "id": "routing-navigation-review", "name": "Routing & Navigation Review", "type": "skill", "provider": "frontend", "harnesses": [ "claude-code", "cursor", "codex", "gemini", "kiro", "other" ], "summary": "Reviews route-tree structure, loader/action placement, code-splitting, and navigation-blocking/focus-management for correctness, security enforcement, and accessibility conformance.", "source_type": "original", "official_docs": [ "https://reactrouter.com/start/framework/route-module", "https://reactrouter.com/start/data/actions", "https://nextjs.org/docs/app/building-your-application/routing/dynamic-routes", "https://www.w3.org/WAI/ARIA/apg/patterns/" ], "security_notes": "Client-side-only route guards (hiding a nav link, redirecting in a component) are UX only; treat any route lacking a paired server-side enforcement point (loader auth check, middleware, or BFF authorization) as a blocking security finding. Static-review-only skill: it reads and greps route source but never executes, builds, or runs application code.", "last_verified": "2026-07-02", "path": "skills/frontend/routing-navigation-review", "author": "github: VincentChuWaiChow", "version": "0.1.0" } -
SKILL.md 9.1 KB
--- name: routing-navigation-review description: Reviews route-tree structure, loader/action placement, code-splitting boundaries, and navigation-blocking/focus-management behavior in React Router and Next.js applications for correctness, server-side security enforcement, and accessibility conformance on route transitions. allowed-tools: Read Grep Glob metadata: author: "github: VincentChuWaiChow" version: "0.1.0" updated: "2026-07-02" category: architecture --- # Routing & Navigation Review ## Purpose Review a frontend application's route tree — path/layout nesting, loader/action data-fetching placement, code-splitting boundaries, and navigation-blocking/focus-management behavior — without re-litigating what data a loader fetches from the backend contract (that is `api-integration-contract-review`) or SSR streaming/hydration mechanics at a route's data boundary (that is `ssr-hydration-streaming-diagnosis`) in every response. This skill exists because route-level defects hide in three distinct, easily-conflated places: a route that is "protected" only by hiding a nav link or redirecting client-side (an authorization bypass reachable by typing the URL directly), a code-split boundary that accidentally serializes loader→component→action instead of loading them in parallel, and a route transition that silently drops keyboard focus with no status announcement (a WCAG 2.4.3 / 4.1.3 failure that is invisible unless you trace it deliberately). ## When to use Use this skill when the user asks to: - review a new route or a route-tree restructuring before merge, - audit whether routes described as "protected" are actually enforced server-side, not just hidden client-side, - investigate broken deep-links, lost filter/pagination/tab state on refresh, or "back button doesn't work right" bugs, - respond to an accessibility audit finding of lost focus or missing status announcements on navigation, - review code-splitting/lazy-loading changes to a route tree for waterfall regressions. Do not use this skill for: - reviewing what data a loader fetches from the backend contract, response shape, or error handling — use `api-integration-contract-review` instead, - SSR streaming/hydration mechanics at a route's data boundary (Suspense boundaries, streaming HTML, hydration mismatches) — use `ssr-hydration-streaming-diagnosis` instead, - component-internal state with no route/URL involvement. ## Context7 Documentation Protocol - Resolve library IDs before citing any framework-specific claim: `/remix-run/react-router` for React Router, `/vercel/next.js` for Next.js. Do not assume API shape from memory — both frameworks' routing/data APIs have changed materially across major versions (React Router v6 object-route API vs. v7 framework-mode route modules; Next.js Pages Router vs. App Router). - Before asserting server-side enforcement patterns in React Router, query `/remix-run/react-router` for "loader authentication redirect" and confirm the current guidance: a `loader` (or a middleware paired with a `loader` to force it to run on every client-side navigation) is the enforcement point — a component-level redirect or conditional render is not, because React Router still renders/matches the route client-side without a network round trip that a server can gate. - Before asserting server-side enforcement patterns in Next.js, query `/vercel/next.js` for "data access layer authorization" and confirm the current guidance: official docs explicitly frame `middleware`/proxy-based checks (cookie-presence checks run at the edge) as an *optimistic* first pass for UX/redirect purposes, and require the actual authorization check to live in a server-only Data Access Layer close to the data source (Server Component, Server Action, or Route Handler) — do not treat a middleware matcher as sufficient enforcement on its own. - Before asserting code-splitting behavior, query `/remix-run/react-router` for "lazy route module" and confirm current guidance: the recommended `lazy` route property loads the component and its `loader`/`action` together (e.g., via `Promise.all`) so they resolve in parallel — a common regression is `await`-ing them in sequence instead. - Before asserting navigation-blocking behavior, query `/remix-run/react-router` for "useBlocker" and confirm current constraints: `useBlocker` only works within data routers (`createBrowserRouter`/framework mode) and explicitly does not intercept hard reloads or cross-origin navigations — do not present it as a universal unsaved-changes guard. - Verify the installed major version of React Router or Next.js (`package.json`) before asserting version-specific route-module or App Router conventions; if the repo is on Pages Router or React Router v5/v6 classic mode, framework-mode/App Router guidance does not transfer directly. - If Context7 is unavailable, fall back to the `official_docs` URLs in this skill's `metadata.json` and label the claim `documentation-based, unverified against current release`. ## Lean operating rules - First map the full route tree (paths, layout nesting, index routes) before evaluating any individual route — a route's effective protection or focus behavior can depend on a parent layout's loader or wrapper. - Classify every route as public or protected using the authorization model the team actually has, not assumption. A route is protected only if there is a server-side enforcement point: a React Router `loader` (or middleware forced to run via a paired loader) that redirects/throws, or a Next.js Data Access Layer check inside a Server Component/Server Action/Route Handler. Hiding a nav link, a client-side `useEffect` redirect, or a component-level conditional render is UX affordance only — treat any "protected" route lacking a paired server-side enforcement point as a blocking (HIGH) security finding, not a style note. - Do not accept a Next.js `middleware`/proxy auth check as sufficient enforcement by itself; official Next.js guidance frames it as an optimistic edge check. Require the corresponding Data Access Layer check to also exist, and flag a middleware-only implementation as a HIGH finding even if the middleware matcher looks correct. - When reviewing code-splitting, verify whether loader/component/action for a lazy route resolve via a parallel construct (e.g., `Promise.all`, or the framework's single `lazy` property that loads them together) versus sequential `await` calls that create a waterfall — the latter is a measurable performance regression, not a style preference. - Treat any view-critical state (active filter, pagination page, selected tab, search query) that lives only in component state/memory as a defect if it should be shareable or survive a refresh — it belongs in the URL (path segment or search params), not only in memory. This is what breaks deep-links and the back button. - Trace focus management on every route transition: identify what receives focus after navigation (a heading, the main landmark, or nothing) and whether an `aria-live` region announces the route/status change for assistive technology. Absence of either is an accessibility (WCAG 2.4.3 Focus Order / 4.1.3 Status Messages) finding, not a nice-to-have. - For form-heavy routes, verify navigation-blocking (e.g., React Router `useBlocker`) exists for unsaved changes, and verify its known limits (SPA-only; does not cover hard reloads or cross-origin navigation) are either accepted knowingly or covered by a `beforeunload` handler for the hard-reload case. - Never execute, build, or run application code as part of this review; this is a static-review skill (Read/Grep/Glob only). Do not attempt to open a browser or simulate navigation to "check" focus behavior — trace it from source (component refs, `useEffect` on location change, `aria-live` regions) and label the finding `repo evidence` with a caveat that runtime confirmation was not performed. ## References Load these only when needed: - [Review workflow and findings contract](references/workflow-and-output.md) — use for the step-by-step review procedure, the route-tree mapping table, and the required output shape. - [Server-side enforcement patterns](references/server-enforcement-patterns.md) — load only when auditing whether a "protected" route has real server-side enforcement (React Router loader/middleware, Next.js Data Access Layer) versus client-side-only gating. - [Code-splitting and URL state](references/code-splitting-and-url-state.md) — load only when reviewing lazy-loading/waterfall regressions or when view-critical state needs to move into the URL. - [Focus management and navigation blocking](references/focus-and-navigation-blocking.md) — load only when reviewing focus/`aria-live` behavior on route transitions or unsaved-changes navigation blocking. ## Response minimum Return, at minimum: - a route-tree table: path, protection level with its server-side enforcement pointer (or "none found"), code-split boundary, and focus-management target, - ranked findings with file:line evidence, - evidence level per finding (`repo evidence`, `documentation-based`, or `inference`), - verdict (approve / approve-with-notes / block), - open questions or scope the review could not cover (e.g., runtime focus behavior not simulated, transitive import not read).
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.