
Remix V2 Routing Review
- 27 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
Helps with ai & agent building tasks.
About
remix-v2-routing-review is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- remix-v2-routing-review
- AI & Agent Building
- AI-coding skill
Remix V2 Routing Review by the numbers
- 27 all-time installs (skills.sh)
- Ranked #9,601 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/existential-birds/beagle --skill remix-v2-routing-reviewAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 27 |
|---|---|
| repo stars | ★ 74 |
| Last updated | July 21, 2026 |
| Repository | existential-birds/beagle ↗ |
What it does
Helps with ai & agent building tasks.
Files
Remix v2 Routing Code Review
Loaded by review-remix-v2 (umbrella) to flag routing anti-patterns in app/routes/ modules. See remix-v2-routing for canonical patterns.
Quick Reference
| Issue Type | Reference |
|---|---|
Filename smells (index.tsx, __auth, wrong escape, non-route files) | references/route-files.md |
Missing <Outlet />, orphan dotted segments, duplicated layout logic | references/layouts-outlets.md |
Default export on a resource, <Link> without reloadDocument, splat params | references/resource-routes.md |
react-router-dom imports, __double folders, v1-adapter fallback | references/v1-holdovers.md |
Missing <Meta />/<Links />/<Scripts />/<ScrollRestoration />, Vite-vs-Classic <LiveReload />, root ErrorBoundary without document shell | references/root-shell.md |
Scope
This skill flags issues in:
- Files under
app/routes/(filenames, exports, imports, JSX shape) app/root.tsx(document shell, root<Outlet />)remix.config.js(entries that change route discovery:routes(),ignoredRouteFiles,@remix-run/v1-route-convention)- Any module that links to a resource route (
<Link>usage)
Out of scope: loader/action data contracts (covered by remix-v2-data-flow-review), form behavior (remix-v2-forms-review), meta/headers (remix-v2-meta-sessions-review).
Review Checklist
- [ ] Index routes named
_index.tsx, notindex.tsx - [ ] Pathless layouts use single underscore (
_auth.tsx), not double (__auth/) - [ ] Dotted child routes (
users.profile.tsx) have a parent module or use trailing underscore (users_.profile.tsx) - [ ] Parent route modules render
<Outlet /> - [ ] Splat segments read
params["*"], neverparams.splat/params.rest - [ ] Literal dots/special chars escaped with brackets (
sitemap[.]xml.tsx) - [ ] Resource routes have no
defaultexport - [ ]
<Link>to a resource route usesreloadDocument(or is a plain<a>) - [ ] Imports come from
@remix-run/react, notreact-router-dom - [ ] Non-route files (CSS, helpers, tests) live in a folder with
route.tsx, or are listed inignoredRouteFiles - [ ] Trailing-underscore opt-outs only used when a parent layout actually exists to escape
- [ ] Optional segments
($lang)narrowparams.langin the loader (see references/route-files.md)
Valid Patterns (Do NOT Flag)
- Resource route with no default export — this is the convention that makes it a resource route. Never flag.
- Any `_`-prefixed pathless layout file without `<Outlet />` when the module is intentionally a wrapper that renders fixed UI only. Confirm by checking children — if no
*.{segment}.tsxsiblings exist, the wrapper-only shape is intentional. - Files prefixed with `_` that don't appear in any URL — pathless layouts and
_indexare supposed to be hidden from the URL. - `@remix-run/v1-route-convention` wired up in
remix.config.js— legitimate migration adapter, not a smell on its own. Only flag if v1-style files appear without the adapter installed. - `useLoaderData<typeof loader>()` — type annotation, not assertion.
- *Splat route accessing `params[""]`** with bracket syntax — that is the only correct access pattern.
- Folder `app/routes/dashboard/` with `route.tsx` plus sibling `.server.ts`, `.css`, component files — co-location is the documented pattern.
- Trailing underscore (`concerts_.mine.tsx`) when a sibling
concerts.tsxlayout exists and this URL intentionally skips it.
Context-Sensitive Rules
Only flag these issues when the specific context applies:
| Issue | Flag ONLY IF |
|---|---|
index.tsx under app/routes/ | Project is v2 and @remix-run/v1-route-convention is NOT wired in remix.config.js |
Parent module without <Outlet /> | Sibling dotted children (parent.*.tsx) exist in app/routes/ |
__double underscore folder | No v1-convention adapter is installed |
| Trailing-underscore segment | No corresponding parent layout exists (nothing to opt out of) |
| Default export on a module returning non-HTML | The loader/action actually returns a raw Response (PDF, JSON, RSS) |
<Link> to resource route | Target route has no default export AND <Link> lacks reloadDocument |
Hard gates (before writing findings)
Run these in order. Do not draft user-facing findings until every gate passes for the batch you are about to report.
1. Location evidence — Pass: Each issue lists a repo path (file under app/routes/ or remix.config.js) and either a line range or a short verbatim quote from the file you read. Filename-only smells must quote the literal filename.
2. Exemption check — Pass: For each issue, state in one line why it is not covered by Valid Patterns (Do NOT Flag). Resource-route flags require explicit evidence of a default export or a <Link> without reloadDocument.
3. Version check — Pass: Confirm the project is Remix v2 (check package.json for @remix-run/react ^2, or presence of v2 flat-routes filenames elsewhere in app/routes/). If @remix-run/v1-route-convention is wired in remix.config.js, v1 filenames (__auth/, index.tsx) are intentional — do not flag them as smells.
4. Protocol — Pass: Complete the Pre-Report Verification Checklist in review-verification-protocol for this review.
When to Load References
- Reviewing filenames under
app/routes/→ references/route-files.md - Reviewing parent modules and shared chrome → references/layouts-outlets.md
- Reviewing a module that returns non-HTML, or any
<Link>to such a module → references/resource-routes.md - Reviewing imports or filenames that look like v1 → references/v1-holdovers.md
- Reviewing
app/root.tsx(document shell,<Meta />/<Links />/<Scripts />/<ScrollRestoration />,<LiveReload />on Vite vs Classic Compiler, rootErrorBoundary) → references/root-shell.md
Review Questions
1. Does every parent route render <Outlet />, or is it a deliberate wrapper-only? 2. Do filenames match the v2 grammar (single _ for pathless, _index for index, [...] for escapes)? 3. Are resource routes free of default exports, and do all <Link>s to them use reloadDocument? 4. Are all router imports from @remix-run/react (and server helpers from @remix-run/node)? 5. If v1 filenames exist, is @remix-run/v1-route-convention wired up — or are they accidental?
Additional Documentation
- Route file naming smells: references/route-files.md —
index.tsx,__doublefolders, wrong escape syntax, dot/underscore confusion, non-route files underapp/routes/. - Layouts and outlets: references/layouts-outlets.md — pathless layout misuse, missing
<Outlet />, orphan dotted children, duplicated layout logic. - Resource routes: references/resource-routes.md — accidental default export,
<Link>withoutreloadDocument, splatparams["*"]access. - v1 holdovers: references/v1-holdovers.md —
react-router-domimports,__authfolders,@remix-run/v1-route-conventionas a deliberate-vs-accidental tell.
Layouts and Outlets
Parent route modules must render <Outlet /> so children can mount inside them. Pathless layouts (_auth.tsx) wrap URLs without adding a segment. Misuse shows up as blank pages, duplicated chrome, and dotted routes whose authors thought there was a layout.
See remix-v2-routing for canonical layout shape.
---
Parent module missing <Outlet />
Pattern: A route module that has dotted child routes (concerts.$city.tsx, concerts._index.tsx) but its parent (concerts.tsx) does not render <Outlet />.
// app/routes/concerts.tsx — smell
import { useLoaderData } from "@remix-run/react";
export default function ConcertsLayout() {
const data = useLoaderData<typeof loader>();
return (
<section>
<nav>{/* subnav */}</nav>
{/* missing <Outlet /> — children never render */}
</section>
);
}Why bad: Without <Outlet />, the parent renders but children mount nowhere. /concerts/salt-lake-city shows the parent chrome and a blank where the city detail should be.
Fix:
import { Outlet } from "@remix-run/react";
export default function ConcertsLayout() {
return (
<section>
<nav>{/* subnav */}</nav>
<Outlet />
</section>
);
}Do NOT flag when the parent module is intentionally a wrapper with fixed content and no sibling children exist in app/routes/. Check: if no concerts.*.tsx siblings exist, the wrapper-only shape may be deliberate (rare; usually a refactor remnant).
---
Pathless layout that forgets to be a layout
Pattern: A _auth.tsx module that renders standalone content with no <Outlet />, while sibling _auth.login.tsx / _auth.signup.tsx files exist.
// app/routes/_auth.tsx — smell
export default function AuthLayout() {
return <div className="auth-shell"><h1>Sign in</h1></div>;
// no Outlet — /login renders the AuthLayout but never the Login child
}Why bad: The pathless parent owns the URL match for /login, but its render output replaces the child instead of wrapping it.
Fix:
import { Outlet } from "@remix-run/react";
export default function AuthLayout() {
return (
<div className="auth-shell">
<Outlet />
</div>
);
}---
Orphan dotted children with no parent module
Pattern: Files like users.profile.settings.tsx exist but users.tsx and users.profile.tsx do not.
app/routes/
users.profile.settings.tsx # exists
users.tsx # missing
users.profile.tsx # missingWhy bad: The URL /users/profile/settings works, but reviewers (and the next developer) will look for a users.tsx layout that doesn't exist. The dotted name implies nesting that the filesystem doesn't back up.
Fix: Either add the missing parent layouts, or use trailing-underscore segments to make the flat intent explicit:
# Make nesting real
app/routes/users.tsx
app/routes/users.profile.tsx
app/routes/users.profile.settings.tsx
# Or make flatness explicit
app/routes/users_.profile_.settings.tsx → /users/profile/settings (flat)---
Duplicated layout chrome in every child
Pattern: Each child route (dashboard.home.tsx, dashboard.billing.tsx, dashboard.team.tsx) renders the same header/sidebar JSX inline instead of pulling it from a parent.
// app/routes/dashboard.home.tsx — smell
export default function DashboardHome() {
return (
<div>
<DashboardSidebar /> {/* duplicated */}
<DashboardHeader /> {/* duplicated */}
<main>{/* page content */}</main>
</div>
);
}Why bad: Layout duplication causes flash-on-navigation (the chrome unmounts and remounts between sibling routes), keyboard-focus loss, and divergence over time as one copy gets updated and others don't. The whole point of nested routes is that the parent stays mounted.
Fix: Lift chrome into dashboard.tsx and render <Outlet />.
// app/routes/dashboard.tsx — single source
import { Outlet } from "@remix-run/react";
export default function DashboardLayout() {
return (
<div>
<DashboardSidebar />
<DashboardHeader />
<main><Outlet /></main>
</div>
);
}
// app/routes/dashboard.home.tsx — child renders only its content
export default function DashboardHome() {
return <h1>Home</h1>;
}---
_index under a pathless parent at root
Pattern: A file named _auth._index.tsx.
Why bad: This renders at / wrapped in the _auth layout — almost never what was intended. Authors usually want _auth.login.tsx (renders at /login) or a top-level _index.tsx outside the auth shell.
Fix: Decide what URL you actually want.
_auth._index.tsx → / (wrapped in AuthLayout) — usually wrong
_auth.login.tsx → /login (wrapped in AuthLayout)
_index.tsx → / (no AuthLayout)---
Index file at a URL where it conflicts with a sibling
Pattern: Both users.tsx and users._index.tsx rendering content, where users.tsx does not render <Outlet />.
Why bad: users.tsx is the layout for the /users segment; users._index.tsx is the child that renders at exactly /users. If the parent doesn't outlet, the index never shows.
Fix: Parent renders <Outlet />, index renders the at-segment content.
// app/routes/users.tsx
import { Outlet } from "@remix-run/react";
export default function UsersLayout() {
return <section><Outlet /></section>;
}
// app/routes/users._index.tsx
export default function UsersIndex() {
return <h1>All users</h1>;
}---
Verification
For each layout/outlet flag:
1. Quote the relevant module(s) and confirm <Outlet /> is absent. 2. List the sibling files in app/routes/ that depend on the parent. 3. Confirm the parent isn't a deliberate wrapper-only (no children → not a layout violation). 4. If pathless: check the URL the file actually produces against the URL the author commented.
Resource Routes
A route module without a default export is a resource route: it returns raw Response objects (PDF, JSON, RSS, sitemap, webhook). Parent loaders do not run, no UI mounts, and <Link> cannot navigate to it client-side without a full document request.
See remix-v2-routing for canonical resource-route shape.
---
Accidental default export on a resource route
Pattern: A module meant to serve JSON, a PDF, or a webhook target also exports a default component.
// app/routes/reports.$id[.pdf].tsx — smell
import type { LoaderFunctionArgs } from "@remix-run/node";
import invariant from "tiny-invariant";
export async function loader({ params }: LoaderFunctionArgs) {
invariant(params.id, "id is required");
const pdf = await generateReportPDF(params.id);
return new Response(pdf, { headers: { "Content-Type": "application/pdf" } });
}
export default function ReportPage() { // <-- turns it into a UI route
return <div>Report</div>;
}Why bad: A default export turns the module into a UI route. Parent loaders run, the error boundary mounts, Remix expects HTML, and the raw-Response fast path is dropped. The downstream symptom is a blank page or a parse error in the browser.
Fix: Delete the default export.
// app/routes/reports.$id[.pdf].tsx — correct resource route
import type { LoaderFunctionArgs } from "@remix-run/node";
import invariant from "tiny-invariant";
export async function loader({ params }: LoaderFunctionArgs) {
invariant(params.id, "id is required");
const pdf = await generateReportPDF(params.id);
return new Response(pdf, { headers: { "Content-Type": "application/pdf" } });
}
// No default export — that is what makes this a resource route.Do NOT flag absence of a default export on its own. Absence is the convention. Only flag when a default export is present and the loader/action returns a non-HTML Response.
---
<Link> to a resource route without reloadDocument
Pattern: Standard <Link to="/api/report.pdf"> or <Link to="/sitemap.xml"> pointing at a resource route.
// smell
import { Link } from "@remix-run/react";
export function DownloadButton({ id }: { id: string }) {
return <Link to={`/reports/${id}.pdf`}>Download</Link>;
}Why bad: Client-side navigation tries to fetch the resource as a Remix route module. The browser ends up with a PDF byte stream where it expected route data, producing a parse error or blank page. The user clicks the link and nothing happens.
Fix: Use reloadDocument on the <Link> (forces a full document request) or use a plain <a>.
import { Link } from "@remix-run/react";
export function DownloadButton({ id }: { id: string }) {
return <Link reloadDocument to={`/reports/${id}.pdf`}>Download</Link>;
}
// Or:
export function DownloadAnchor({ id }: { id: string }) {
return <a href={`/reports/${id}.pdf`}>Download</a>;
}---
Splat params accessed by the wrong key
Pattern: A splat route reading params.splat, params.rest, params.path, or any name other than "*".
// app/routes/files.$.tsx — smell
import type { LoaderFunctionArgs } from "@remix-run/node";
export async function loader({ params }: LoaderFunctionArgs) {
const path = params.splat; // undefined — wrong key
return new Response(await readBlob(path), { /* … */ });
}Why bad: Splat values are stored under the "*" key in params, not under a name derived from the filename. params.splat is always undefined, so the resource serves nothing or 500s on the undefined argument.
Fix: Bracket-access params["*"] and narrow before use.
import type { LoaderFunctionArgs } from "@remix-run/node";
export async function loader({ params }: LoaderFunctionArgs) {
const rest = params["*"];
if (!rest) throw new Response("Not found", { status: 404 });
return new Response(await readBlob(rest), { /* … */ });
}There is no named splat in v2 — bracket access is the only correct way.
---
Relying on parent loader side effects in a resource route
Pattern: A resource route assumes its _auth.tsx parent runs first and rejects unauthenticated requests, but the resource is requested directly (e.g., a <Link reloadDocument> or external curl).
Why bad: Parent loaders do not run for resource routes. Auth checks living only in a parent _layout.tsx are silently skipped on GET /api/sensitive.json.
Fix: Run the auth check inside the resource route's own loader (or action), not in a parent layout.
// app/routes/api.sensitive[.]json.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { requireUser } from "~/sessions.server";
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireUser(request); // local auth check
return json(await loadSensitiveDataFor(user.id));
}---
Mixing <Form> action with a resource-route action
Pattern: A resource route exposes action() and is the target of a <Form action="/api/webhook">, but the form expects to revalidate parent loaders or navigate after submit.
Why bad: Resource-route actions don't trigger parent-loader revalidation the way UI-route actions do. Forms posting to a resource route should expect a raw Response back, not a navigation.
Fix: For mutations that need revalidation, post to the owning UI route's action. Reserve resource-route actions for webhooks or programmatic clients that consume the raw Response.
---
Verification
For each resource-route finding:
1. Confirm the module has (or lacks) a default export — quote the export line. 2. Confirm what the loader/action returns (HTML, JSON, raw Response). 3. For <Link> flags: confirm the to target maps to a resource route (no default export) and the <Link> lacks reloadDocument. 4. For splat flags: quote the params.<key> access — if it's not params["*"], it's wrong.
Root Shell Smells (app/root.tsx)
app/root.tsx owns the entire document. The six elements (<Meta />, <Links />, <Outlet />, <ScrollRestoration />, <Scripts />, and <LiveReload /> for Classic Compiler only) are load-bearing. Each smell below is a missing or misplaced shell element that breaks a documented Remix feature.
See remix-v2-routing root.md for the canonical scaffold and the Layout export pattern.
---
Missing <Meta /> in <head>
Pattern: app/root.tsx renders a <head> block without <Meta />.
// app/root.tsx — smell
export default function App() {
return (
<html lang="en">
<head>
<title>My App</title>
<Links />
</head>
<body>{/* ... */}</body>
</html>
);
}Why bad: Without <Meta />, every meta export from descendant routes is silently dropped. <title>, og:, twitter:, viewport, and description tags from route modules never reach the document.
Fix: Add <Meta /> to <head> (it may sit alongside hand-written tags).
---
Missing <Links /> in <head>
Pattern: app/root.tsx omits <Links />.
Why bad: Stylesheet, preload, and icon links exports from descendant routes never render. CSS doesn't reach the browser, and the page flashes unstyled or never styles at all.
Fix: Include <Links /> in <head>.
---
Missing <Scripts /> in <body>
Pattern: app/root.tsx renders without <Scripts />.
Why bad: The Remix runtime and route-module bundles never load. Server HTML renders, but the app never hydrates — no client loaders, no client actions, no SPA navigation, no prefetch. Forms still submit because the browser handles them natively, but everything Remix-specific is dead.
Fix: Include <Scripts /> in <body>, after <Outlet /> and <ScrollRestoration />.
---
Missing <ScrollRestoration />
Pattern: app/root.tsx omits <ScrollRestoration /> (or places it after <Scripts />).
Why bad: Back/forward navigation doesn't restore scroll. New navigations don't reset to top. Placing it after <Scripts /> lets hydration clobber the scroll-restore script. Place it before <Scripts />.
Fix: First element in <body> after <Outlet />, before <Scripts />.
---
Conditional <LiveReload /> (Classic Compiler vs Vite)
Pattern A — Classic Compiler, conditionally rendered: {process.env.NODE_ENV === "development" && <LiveReload />}.
Why bad: <LiveReload /> already no-ops in production. The conditional is dead weight and signals the author doesn't trust the framework.
Fix (Classic Compiler): Render <LiveReload /> unconditionally.
Pattern B — Vite plugin, `<LiveReload />` still rendered: The project uses @remix-run/dev/vite (default for new v2 apps) and app/root.tsx still imports and renders <LiveReload />.
Why bad: <LiveReload /> is for the Classic Compiler only. Vite has its own HMR and the Remix element is dead weight (or a runtime warning) under Vite.
Fix (Vite): Delete the LiveReload import and the <LiveReload /> element entirely.
See routing/references/root.md for the Vite-vs-Classic-Compiler split.
---
Root ErrorBoundary without document shell
Pattern: An ErrorBoundary export in app/root.tsx that returns a bare fragment or <div>.
// app/root.tsx — smell
export function ErrorBoundary() {
const error = useRouteError();
return <h1>Application Error</h1>;
}Why bad: The root ErrorBoundary replaces the entire document (it is not mounted inside <Outlet />). Without <html>, <head>, <body>, <Meta />, <Links />, <Scripts />, and <ScrollRestoration />, the error page renders unstyled, unhydrated, and without the document shell — often as a stark white page with raw text.
Fix: Return the full document, or use the Layout export pattern (Remix >= 2.4) so Layout wraps ErrorBoundary automatically. See routing/references/root.md.
---
Verification
For each root-shell finding:
1. Quote the missing/misplaced element from app/root.tsx. 2. Confirm the project's compiler — Classic vs Vite — by checking vite.config.ts or remix.config.js. <LiveReload /> is correct on Classic and wrong on Vite; do not flag without confirming. 3. Confirm Remix version. The Layout export is >= 2.4; on older versions the hand-rolled Document wrapper is the correct fix. 4. For ErrorBoundary flags, confirm the boundary actually returns a fragment / partial DOM, not a full document.
Route File Naming Smells
Flat-routes v2 reads filenames literally: dots become URL slashes, _ prefixes hide segments, brackets escape, and anything under app/routes/ without a folder wrapper is treated as a route module. The smells below are violations of that grammar.
See remix-v2-routing for the canonical filename table.
---
index.tsx instead of _index.tsx
Pattern: A file named index.tsx (no leading underscore) under app/routes/ in a v2 project.
Why bad: In v2 flat-routes, index.tsx is interpreted as a literal URL segment, producing /index. The leading underscore is what marks it as an index route. Files left over from a v1 codebase silently route to the wrong URL after upgrade.
Fix:
# Before
app/routes/index.tsx → /index (wrong)
app/routes/concerts/index.tsx → /concerts/index (wrong, also v1 folder shape)
# After
app/routes/_index.tsx → /
app/routes/concerts._index.tsx → /concerts---
__double underscore folders (v1 pathless layouts)
Pattern: A folder under app/routes/ named __auth/, __app/, etc., containing route files.
Why bad: v1 used double-underscore folders for pathless layouts (app/routes/__auth/login.tsx). v2 flat-routes uses a single-underscore file (app/routes/_auth.tsx + app/routes/_auth.login.tsx). v2's filesystem walker does not recognize the double-underscore folder convention — the routes either fail to mount or mount with the wrong URL.
Fix:
# Before (v1)
app/routes/__auth/login.tsx → expected /login under shared layout
app/routes/__auth/signup.tsx
# After (v2)
app/routes/_auth.tsx → pathless layout module with <Outlet />
app/routes/_auth.login.tsx → /login (inherits _auth)
app/routes/_auth.signup.tsx → /signup (inherits _auth)If the v1 tree must stay during migration, wire @remix-run/v1-route-convention in remix.config.js — see references/v1-holdovers.md. Don't flag double-underscore folders when the adapter is installed.
---
Wrong escape syntax for literal characters
Pattern: Attempting to escape a literal dot, dollar, or other convention character with a backslash, quotes, or HTML entities.
sitemap\.xml.tsx # backslash — no
"sitemap.xml".tsx # quotes — no
$bill.tsx # author meant literal /$bill but $ is a dynamic param markerWhy bad: Only [...] brackets escape convention characters in v2. Anything else leaves the special character active — the dot becomes a path delimiter, the $ becomes a dynamic segment ($bill.tsx matches /anything and stores it under params.bill).
Fix: Wrap the literal character(s) in brackets.
sitemap[.]xml.tsx → /sitemap.xml
reports.$id[.pdf].tsx → /reports/:id.pdf
[$]bill.tsx → /$bill (literal dollar sign in URL)Splat misuse ($.tsx placed somewhere it shouldn't be) is a separate smell — it is the correct escape for "catch the rest", not a literal-character problem. See resource-routes.md for splat-key errors.
---
Dot vs underscore confusion
Pattern: Using a dot where an underscore was meant (or vice versa), creating routes that don't match the URL the author wrote in the comment.
_auth.login.tsx → /login (correct — pathless _auth, child login)
_auth_login.tsx → /_auth_login (literal — author probably wanted /login)
_auth/login.tsx → ignored or broken (v1-style folder, no route.tsx)
auth._login.tsx → /auth/_login (leading underscore on child is a hidden index)Why bad: Each character is grammar:
.= URL slash and parent nesting_prefix = pathless (no URL contribution) or index marker_suffix on a segment = opt-out of layout nesting
Mixing them produces URLs that don't match intent and routes that miss their layout.
Fix: Pick the right grammar. Quick guide:
| Goal | Filename | URL |
|---|---|---|
| URL with shared layout | parent.child.tsx | /parent/child |
| URL without shared layout | parent_.child.tsx | /parent/child |
| Shared layout, no URL segment | _auth.tsx + _auth.x.tsx | /x |
| Index under a layout | parent._index.tsx | /parent |
---
Non-route files directly under app/routes/
Pattern: CSS, server-only helpers, test files, or component files placed at app/routes/something.css, app/routes/utils.server.ts, app/routes/Chart.test.tsx, etc.
Why bad: Remix's filesystem walker treats every file in app/routes/ as a route module unless told otherwise. Non-route files surface as build warnings, mount as broken routes, or land at unexpected URLs.
Fix: Either move into a folder with a route.tsx, or list them in ignoredRouteFiles.
# Folder convention — only route.tsx becomes a route
app/routes/dashboard/
route.tsx
queries.server.ts # not a route
Chart.tsx # not a route
Chart.test.tsx # not a route// remix.config.js — ignore by glob
/** @type {import('@remix-run/dev').AppConfig} */
export default {
ignoredRouteFiles: ["**/.*", "**/*.css", "**/*.test.*", "**/*.server.*"],
};Vite-based v2 projects use vite.config.ts with the Remix Vite plugin instead of remix.config.js.
---
Trailing-underscore opt-out with no layout to escape
Pattern: A file named admin_.users.tsx when no sibling admin.tsx (parent layout) exists.
Why bad: The trailing underscore is meaningful only when there is a parent layout to skip. Without one, the underscore is noise — reviewers waste time looking for the layout it's escaping. Also signals the author misunderstands the grammar, which often hides a deeper routing bug.
Fix: Drop the trailing underscore. Or, if a parent layout should exist, add it (admin.tsx).
# Smell
app/routes/admin_.users.tsx # no admin.tsx exists
# Fix
app/routes/admin.users.tsx---
Optional segments ($lang) without narrowing in the loader
Pattern: A route uses an optional segment like ($lang)._index.tsx and the loader uses params.lang without checking for undefined.
// app/routes/($lang)._index.tsx — smell
export async function loader({ params }: LoaderFunctionArgs) {
const messages = await loadMessages(params.lang); // undefined on /
return json(messages);
}Why bad: Optional segments are optional — params.lang is string | undefined. On / (no lang), it is undefined; on /en, it is "en". Passing undefined downstream silently produces wrong data, a 500, or a redirect loop.
Fix: Narrow before use — default, redirect, or 404.
// app/routes/($lang)._index.tsx — correct
export async function loader({ params }: LoaderFunctionArgs) {
const lang = params.lang ?? "en";
const messages = await loadMessages(lang);
return json(messages);
}---
Verification
For each route-file smell:
1. List the path (app/routes/<file>). 2. Quote the literal filename. 3. Confirm v2 (see Hard gate 3 in SKILL.md). 4. Cross-check against remix.config.js — @remix-run/v1-route-convention and ignoredRouteFiles change which files are legitimate.
v1 Holdovers
Remix v2 re-exports its router surface from @remix-run/react. Imports from react-router-dom, double-underscore folders, and v1 file shapes are nearly always migration leftovers that bypass Remix's loader/action wiring. The v1-convention adapter is legitimate when wired explicitly — flag it only when its presence is inconsistent with the rest of the tree.
See remix-v2-routing for canonical v2 imports and filename grammar.
---
Importing from react-router-dom
Pattern: A route module or component imports Outlet, Link, useLoaderData, useParams, or any hook from react-router-dom in a Remix v2 project.
// smell
import { Outlet, Link, useParams } from "react-router-dom";Why bad: Remix v2 wraps React Router with loader/action wiring, type inference for useLoaderData<typeof loader>(), and SSR-aware navigation. Importing the underlying react-router-dom package bypasses Remix's wiring — types break, loaders don't connect, and SSR hydration mismatches surface at runtime.
Fix:
import { Outlet, Link, useParams, useLoaderData } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/node"; // or /cloudflare, /denoAllowed exception: importing types like Path from react-router-dom is sometimes necessary when @remix-run/react doesn't re-export them. Flag value imports; check type imports against the actual export surface before flagging.
---
__double underscore folders left from v1
Pattern: Folders under app/routes/ named __auth/, __app/, __marketing/, with route files inside.
app/routes/__auth/login.tsx # v1 pathless layout shape
app/routes/__auth/signup.tsx(v1 used __auth/ as a folder name. A .tsx file literally named __auth.tsx at the routes root is a malformed mix of v1 and v2 grammar — it's neither a v1 pathless layout nor a v2 pathless layout. Treat it as a typo for _auth.tsx.)
Why bad: v1's double-underscore-folder convention is not recognized by v2 flat-routes. The walker either ignores the folder or mounts files at wrong URLs.
Fix: Rename to single-underscore files.
app/routes/_auth.tsx → pathless layout (no URL)
app/routes/_auth.login.tsx → /login
app/routes/_auth.signup.tsx → /signupDo NOT flag when @remix-run/v1-route-convention is installed and wired (see below).
---
index.tsx left from v1
Pattern: Files named index.tsx (no leading _) under app/routes/ or inside a v1-style folder.
app/routes/index.tsx # v1 root index
app/routes/dashboard/index.tsx # v1 nested indexWhy bad: v2 reads these as literal /index segments. The home page mysteriously moves from / to /index after the upgrade.
Fix:
app/routes/_index.tsx → /
app/routes/dashboard._index.tsx → /dashboard---
@remix-run/v1-route-convention as a deliberate-vs-accidental tell
Pattern: The package appears in package.json and remix.config.js wires the v1 convention:
// remix.config.js
import { createRoutesFromFolders } from "@remix-run/v1-route-convention";
/** @type {import('@remix-run/dev').AppConfig} */
export default {
routes(defineRoutes) {
return createRoutesFromFolders(defineRoutes);
},
};This is not automatically a smell. It is the documented migration adapter — a team can legitimately keep a v1 nested-folder tree alive while shipping new v2 flat-routes alongside it.
Flag only when:
- The adapter is present but no v1-style files exist anywhere in
app/routes/— the package is dead weight; remove it frompackage.jsonandremix.config.js. - The adapter is absent but v1-style files (
__auth/,index.tsx) exist — those files won't route correctly; either delete them or install the adapter. - Mixed grammars within the same feature folder — e.g.,
app/routes/__auth/login.tsx(v1) alongsideapp/routes/_auth.signup.tsx(v2). Pick one per subtree.
Fix: Either commit to v2 grammar (delete the adapter, rename files) or commit to v1 (keep the adapter, rename strays back to folders). Don't half-migrate.
---
v1-style hooks and helpers
Pattern: Code uses useTransition (v1) instead of useNavigation (v2), or useFetchers semantics that assume v1 behavior.
Why bad: v2 renamed useTransition to useNavigation and removed the type and submission fields. The state values ("idle" | "submitting" | "loading") are unchanged from v1. Code copied from v1 docs or older blog posts will type-check against React's useTransition (a different API entirely) or silently misbehave when it reaches for the removed type/submission properties.
Fix:
// v1
import { useTransition } from "@remix-run/react";
const transition = useTransition();
if (transition.state === "submitting") { /* … */ }
// v2
import { useNavigation } from "@remix-run/react";
const navigation = useNavigation();
if (navigation.state === "submitting") { /* … */ }Note: React's own useTransition from react is a different hook entirely — don't conflate.
---
json() / redirect() imported from @remix-run/react
Pattern: Server helpers imported from the React entry instead of the runtime entry.
// smell
import { json, redirect } from "@remix-run/react";Why bad: json and redirect are server runtime helpers and live in the runtime package (@remix-run/node, /cloudflare, /deno). Importing them from @remix-run/react either fails to resolve or pulls server code into the client bundle.
Fix: Match the runtime adapter the app uses.
import { json, redirect } from "@remix-run/node";---
Verification
For each v1-holdover flag:
1. Quote the offending import line or filename. 2. Confirm the project is v2 (@remix-run/react ^2 in package.json). 3. Check remix.config.js for @remix-run/v1-route-convention — if wired, v1 filenames are intentional. 4. For import flags: confirm the symbol exists in the suggested replacement entry (@remix-run/react vs @remix-run/node).