
Remix V2 Routing
- 27 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
Helps with ai & agent building tasks.
About
remix-v2-routing is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- remix-v2-routing
- AI & Agent Building
- AI-coding skill
Remix V2 Routing 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-routingAdd 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
Quick Reference
Flat-routes v2 filename rules (all files live in app/routes/):
_index.tsx → /
concerts.tsx → /concerts (acts as layout when dotted children exist; otherwise leaf for /concerts)
concerts._index.tsx → /concerts (renders under layout)
concerts.$city.tsx → /concerts/:city params.city
concerts.trending.tsx → /concerts/trending
_auth.tsx + _auth.login.tsx → /login (pathless layout, no URL segment)
files.$.tsx → /files/* params["*"]
($lang)._index.tsx → / and /en (or /fr etc.) — optional segment
sitemap[.]xml.tsx → /sitemap.xml (escape literal)
concerts_.mine.tsx → /concerts/mine (opts out of layout)
dashboard/route.tsx → /dashboard (folder + route.tsx)
reports.$id[.pdf].tsx → /reports/:id.pdf (no default export = resource)Imports — always use `@remix-run/react`, never `react-router-dom`:
import { Outlet, Link, useLoaderData, useParams } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/node"; // or /cloudflare, /denoFile Naming Conventions
Dots in filenames create URL slashes and parent/child nesting. Underscore prefix marks pathless segments (_auth.tsx) and index routes (_index.tsx). Trailing underscore (concerts_.mine.tsx) opts out of layout nesting while keeping the URL nested. Brackets escape literal characters: sitemap[.]xml.tsx. Splat is the single dollar sign: $.tsx exposes the rest of the path under params["*"]. Optional segments are wrapped in parens: ($lang).
See references/conventions.md for the full table and edge cases.
Nested Layouts
A parent module (concerts.tsx) renders <Outlet />; child routes (concerts.$city.tsx, concerts._index.tsx) mount inside it automatically based on the dot-delimited filename.
// app/routes/concerts.tsx
import { Outlet } from "@remix-run/react";
export default function ConcertsLayout() {
return (
<section>
<nav>{/* concerts subnav */}</nav>
<Outlet />
</section>
);
}// app/routes/concerts._index.tsx (renders at exactly /concerts)
export default function ConcertsIndex() {
return <h1>Browse concerts</h1>;
}For a layout with no URL contribution, prefix with a single underscore:
// app/routes/_auth.tsx → wraps /login, /signup; no URL segment
// app/routes/_auth.login.tsx → /login (inherits _auth layout)
// app/routes/_auth.signup.tsx → /signupDynamic Segments and Splats
$name captures a single segment; $.tsx captures the rest of the path. Loader receives values via params:
// app/routes/concerts.$city.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
export async function loader({ params }: LoaderFunctionArgs) {
if (!params.city) throw new Response("Not found", { status: 404 });
return json({ city: params.city });
}
export default function City() {
const { city } = useLoaderData<typeof loader>();
return <h1>{city}</h1>;
}Splat values live under "*" — there is no params.splat:
// app/routes/files.$.tsx
export async function loader({ params }: LoaderFunctionArgs) {
const rest = params["*"]; // bracket access only
return new Response(await readBlob(rest), { headers: { "Content-Type": "application/octet-stream" } });
}Root Module
app/root.tsx is the only required route. It owns the document shell and must render <Meta />, <Links />, <Outlet />, <ScrollRestoration />, <Scripts />, and (during dev) <LiveReload />. See references/root.md.
Resource Routes
A route module without a default export is a resource route — it returns raw Response objects (PDF, JSON, RSS, webhooks). Parent loaders do not run, and <Link> must use reloadDocument (or be replaced with <a>) to trigger a real document request. See references/resource-routes.md.
Gates (decision sequencing)
Answer in order. Pass means the condition is true; pick the answer on the same line and stop.
Layout vs flat URL
0. Is there shared chrome at all (nav, breadcrumbs, sidebar) at this level?
- Fail → Use plain dotted segments (
about.tsx,pricing.tsx); no layout module needed. Stop. - Pass → Step 1.
1. Should this URL share UI (nav, breadcrumbs, sidebar) with a parent path?
- Pass → Use dot-delimited nesting (
concerts.$city.tsxunderconcerts.tsx). Stop. - Fail → Step 2.
2. *Does the URL just happen to be nested but should render standalone*?
- Pass → Trailing underscore (
concerts_.mine.tsx). Stop. - Fail → Step 3.
3. Need a wrapper layout but no parent URL segment?
- Pass → Single-underscore pathless parent (
_auth.tsx+_auth.login.tsx). Stop.
UI route vs resource route
1. Does this URL ever render HTML to a user?
- Pass → Export a
defaultcomponent. UI route. Stop. - Fail → Step 2.
2. Returns JSON, PDF, RSS, sitemap, webhook, or other raw `Response`?
- Pass → Omit
defaultexport — module becomes a resource route. UsereloadDocumenton any<Link>pointing to it. Stop.
_index.tsx vs index.tsx
1. On Remix v2 with flat-routes?
- Pass →
_index.tsx(leading underscore). Stop. - Fail → Step 2 — you're on v1 (or using the v1 fallback adapter).
2. Need to keep a v1 nested-folder tree alive?
- Pass → Install
@remix-run/v1-route-conventionand wire it inremix.config.js. See references/v1-migration.md. Stop.
Additional Documentation
- Conventions: See references/conventions.md for the full filename grammar (
_index,_layout,$param, splat, optional, escape, folder +route.tsx, trailing underscore). - Root module: See references/root.md for the
root.tsxscaffold and the required document elements. - Resource routes: See references/resource-routes.md for non-HTML responses, the no-
default-export rule, parent-loader skipping, andreloadDocument. - v1 → v2 migration: See references/v1-migration.md for differences from v1 (
__doubleunderscore folders,index.tsx,@remix-run/v1-route-convention,ignoredRouteFiles).
v1 vs v2 Convention Comparison
| Concern | v1 (nested folders) | v2 (flat routes) |
|---|---|---|
| Index route | index.tsx | _index.tsx |
| Pathless layout | __auth/ (double underscore) | _auth.tsx (single) |
| Nested URL | folder hierarchy | dot delimiter in filename |
| Dynamic segment | $param.tsx | $param.tsx (unchanged) |
| Splat | $.tsx | $.tsx (unchanged) |
| Escape literal | n/a | [.], [] brackets |
| Opt-out of layout | move out of folder | trailing _ (foo_.bar.tsx) |
| Co-location | adjacent files in folder | feature/route.tsx + siblings |
| Fallback adapter | n/a | @remix-run/v1-route-convention |
Flat-Routes v2 Conventions
Remix v2 uses a flat-file routing convention. Every file under app/routes/ becomes a route module; the filename itself encodes URL structure, nesting, and special behavior. Dots delimit URL segments, underscores mark pathless/index roles, dollar signs introduce dynamic segments, and brackets escape literal characters.
Convention Table
| Convention | Syntax | URL / Behavior |
|---|---|---|
| Index route | _index.tsx, concerts._index.tsx | Renders at the parent's exact URL |
| Dot delimiter | concerts.trending.tsx | /concerts/trending (also creates parent nesting) |
| Pathless layout | _auth.tsx, _auth.login.tsx | Shared layout with no URL segment |
| Dynamic segment | concerts.$city.tsx | params.city |
| Splat (catch-all) | $.tsx, files.$.tsx | params["*"] (matches incl. slashes) |
| Optional segment | ($lang)._index.tsx | Matches / and /en; params.lang may be undefined |
| Escape literal | sitemap[.]xml.tsx | /sitemap.xml |
| Folder route | dashboard/route.tsx | /dashboard; other files in the folder are inert |
| Opt-out of nesting | concerts_.mine.tsx | /concerts/mine, but does NOT inherit layout |
| Resource route | no default export | Returns non-HTML Response |
Index Routes
_index.tsx (with the leading underscore) is the v2 index marker. It renders at the parent's URL — not its own. So:
app/routes/
_index.tsx → /
concerts.tsx → /concerts (layout)
concerts._index.tsx → /concerts (inside the layout's <Outlet />)A plain index.tsx (no underscore) in v2 is treated as a literal /index URL. Leftover index.tsx files post-upgrade silently produce wrong URLs.
Dot Delimiter and Nested Layouts
Each dot in a filename creates both a URL slash and a parent/child relationship. The longest matching prefix wins as the parent layout:
app/routes/
concerts.tsx → /concerts (parent — renders <Outlet />)
concerts._index.tsx → /concerts (child)
concerts.$city.tsx → /concerts/:city (child)
concerts.trending.tsx → /concerts/trendingIf a deeply dotted file has no parent module (e.g. users.profile.settings.tsx but no users.tsx or users.profile.tsx), the route still works — it just has no layout. That can confuse reviewers, so add the parent module or rename with trailing underscores to make the flat intent explicit.
Pathless Layouts (_name)
A single leading underscore makes the segment pathless — it adds layout nesting without contributing to the URL:
app/routes/
_auth.tsx (no URL segment; renders <Outlet />)
_auth.login.tsx → /login (inherits _auth)
_auth.signup.tsx → /signup (inherits _auth)// app/routes/_auth.tsx
import { Outlet } from "@remix-run/react";
export default function AuthLayout() {
return <div className="auth-shell"><Outlet /></div>;
}Watch out: _auth._index.tsx renders at / with the auth layout — almost never what was intended. Watch for accidental URL collisions when combining pathless parents with _index.
Dynamic Segments ($name)
A segment beginning with $ captures the URL value under that key in params:
// app/routes/concerts.$city.tsx → /concerts/salt-lake-city
import type { LoaderFunctionArgs } from "@remix-run/node";
export async function loader({ params }: LoaderFunctionArgs) {
// params.city is `string | undefined` — narrow before use
if (!params.city) throw new Response("Not found", { status: 404 });
return { city: params.city };
}Splat Route ($.tsx)
A literal $ (no name) catches the rest of the URL, including embedded slashes. The value lives under the "*" key, not under splat or rest:
// app/routes/files.$.tsx → /files/anything/here/even/slashes
export async function loader({ params }: LoaderFunctionArgs) {
const path = params["*"]; // bracket access only — there is no named splat
return new Response(await readBlob(path), {
headers: { "Content-Type": "application/octet-stream" },
});
}Optional Segments ((segment))
Wrapping a segment in parentheses makes it optional:
app/routes/($lang)._index.tsx matches / and /enThe key is always present in params — just possibly undefined. Don't assume it's omitted from the object. If zero-arg and one-arg behavior diverge significantly, prefer two explicit routes over one optional segment.
Escaping Literal Characters
Only square brackets escape convention characters. Backslashes and quotes do not work:
sitemap[.]xml.tsx → /sitemap.xml
reports.$id[.pdf].tsx → /reports/:id.pdfWithout the brackets, the dot becomes a nesting delimiter, and sitemap.xml.tsx would produce /sitemap/xml.
Folder-Based Co-location
You can promote a folder to a route by placing route.tsx inside it. Every other file in the folder is not a route — it is colocated source the route imports:
app/routes/
dashboard/
route.tsx ← becomes the route module (/dashboard)
queries.server.ts ← NOT a route
Chart.tsx ← NOT a routeWithout a route.tsx, the folder is ignored entirely. This pattern keeps server helpers, components, and tests next to the route module without polluting the URL space.
Opt-Out of Layout Nesting (Trailing _)
A trailing underscore on a segment keeps the URL nested but skips layout inheritance:
app/routes/
concerts.tsx → /concerts (layout)
concerts._index.tsx → /concerts (uses layout)
concerts.$city.tsx → /concerts/:city (uses layout)
concerts_.mine.tsx → /concerts/mine (does NOT use layout)Use sparingly: a trailing underscore signals to readers that you're escaping a parent layout that exists. Don't add it when there's no parent layout to escape — it just creates noise.
Resource Routes
A route module with no default export becomes a resource route — it returns a raw Response (PDF, JSON, RSS, webhook target) rather than HTML. See resource-routes.md for full coverage.
Common Mistakes
- Using v1
__doubleunderscore folders or plainindex.tsx— both silently break in v2. - Splat reader writing
params.splatinstead ofparams["*"]. - Escaping a dot with a backslash (
sitemap\.xml.tsx) instead of brackets. - Trailing underscore on a route whose parent doesn't exist — pure noise.
- Importing
Outlet/Link/useLoaderDatafromreact-router-dominstead of@remix-run/react. - Putting CSS, server helpers, or test files directly under
app/routes/without folder convention — they get treated as routes. Either move into afeature/folder or addignoredRouteFilestoremix.config.js.
Resource Routes
A resource route is a route module that returns a raw HTTP Response instead of HTML. PDFs, sitemaps, RSS feeds, JSON APIs, OAuth callbacks, webhooks, and file downloads are all resource routes.
The Defining Rule: No default Export
A route becomes a resource route when its module has no `default` export. That single fact controls everything else about its behavior — there's no decorator, no config flag, no naming convention. The presence or absence of export default function is the switch.
// app/routes/reports.$id[.pdf].tsx → /reports/42.pdf
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, {
status: 200,
headers: { "Content-Type": "application/pdf" },
});
}
// NOTE: no `export default` — this is what makes it a resource route.If you add a default export — even an empty one — Remix treats this as a UI route and the resource-route fast path disappears.
What Changes When There's No Default Export
| Behavior | UI route (default export) | Resource route (no default) |
|---|---|---|
| Response type | HTML document | Raw Response |
| Parent loaders run | Yes | No |
ErrorBoundary mount | Yes | No (errors propagate) |
<Link> navigation | Client-side | Requires reloadDocument |
| Meta / Links / Scripts | Injected | Not injected |
| Hydration | Yes | N/A |
The most important entry: parent loaders are skipped. A GET to a resource route does not invoke ancestor loaders. Auth checks that live in a _layout.tsx parent will not run. Re-check auth inside the resource route's own loader.
Linking to Resource Routes
A normal <Link> tries to fetch the route the way it would fetch any other Remix route. For a resource route, that yields a parse error or a blank page because Remix is expecting a route-shaped response. Two valid options:
import { Link } from "@remix-run/react";
// 1. Force a full document request via Remix's Link
<Link reloadDocument to="/reports/42.pdf">Download</Link>
// 2. Or just use a plain anchor
<a href="/reports/42.pdf">Download</a>reloadDocument tells Remix: don't client-route this — let the browser do a regular navigation, which is what binary responses need.
Example: Sitemap
// app/routes/sitemap[.]xml.tsx → /sitemap.xml
import type { LoaderFunctionArgs } from "@remix-run/node";
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const xml = await buildSitemap(url.origin);
return new Response(xml, {
status: 200,
headers: {
"Content-Type": "application/xml",
"Cache-Control": "public, max-age=3600",
},
});
}Note the [.] bracket-escape on the filename so the dot is treated as a literal character, not a URL-segment delimiter.
Example: JSON API Endpoint
// app/routes/api.users.$id.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import invariant from "tiny-invariant";
export async function loader({ params, request }: LoaderFunctionArgs) {
await requireApiKey(request); // parent loaders DID NOT run
invariant(params.id, "id is required");
const user = await getUser(params.id);
if (!user) throw new Response("Not found", { status: 404 });
return json(user);
}json() from @remix-run/node is a thin wrapper over new Response(JSON.stringify(...), { headers: { "Content-Type": "application/json" } }) — use it freely in resource routes.
Example: Webhook Receiver (Action Only)
A resource route can export only action if it's purely a write endpoint:
// app/routes/webhooks.stripe.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
const event = await verifyStripeSignature(request);
await handleStripeEvent(event);
return new Response(null, { status: 204 });
}No loader, no default export — GETs will 405 from Remix's default method handling.
Common Mistakes
- Default export on a JSON/PDF/webhook route: Turns it into a UI route. Parent loaders run, an
ErrorBoundarymounts, Remix expects HTML. Delete the default export. - `<Link to="/api/foo.pdf">` without
reloadDocument: Client navigation tries to parse the binary as a route module. Returns a parse error or blank page. - Relying on auth in a parent layout's loader: Parent loaders don't run for resource routes. Re-do the auth check inline.
- Forgetting the bracket escape on dotted URLs:
sitemap.xml.tsxproduces/sitemap/xml, not/sitemap.xml. Usesitemap[.]xml.tsx. - Returning a plain string or object: Always return a
Response(orjson(...)). Returning a string yieldsContent-Type: text/htmland a status the browser may not handle correctly for non-HTML payloads.
Root Module (app/root.tsx)
app/root.tsx is the only required route in a Remix v2 app. It owns the entire document shell — <html>, <head>, <body> — and is the rendering ancestor of every other route. Routes mount inside <Outlet />.
Canonical Scaffold
// app/root.tsx
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
export default function App() {
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}All six elements are load-bearing; omitting any of them breaks a documented Remix feature.
Vite plugin (default for new v2 apps): omit `<LiveReload />`. Vite's own HMR handles dev reload. This element is for the Classic Compiler only. The scaffold above shows the Classic Compiler shape; on Vite, delete theLiveReloadimport and the<LiveReload />element.
The Six Required Elements
<Meta />
Renders every meta export collected from the current route and all its ancestors. Place it in <head> so the browser sees <title>, og:, twitter:, and viewport tags before paint.
// any route module
export const meta = () => [
{ title: "Concerts" },
{ name: "description", content: "Browse upcoming concerts." },
];<Links />
Renders every links export from the route tree. Use it for stylesheets, preloads, and icons. Place it in <head> so CSS is applied before first paint.
// app/root.tsx (or any route)
import type { LinksFunction } from "@remix-run/node";
import styles from "./styles/app.css";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: styles },
];<Outlet />
The mount point for child routes. The matched route — and any nested layouts — render here. Without <Outlet />, no route renders.
<ScrollRestoration />
Restores scroll position on back/forward navigation and resets to top on new navigations. Place it as the first element in <body> after <Outlet /> so the script runs before <Scripts /> mounts.
<Scripts />
Loads the Remix runtime and route module bundles. Without it, the app renders server HTML and never hydrates — links and forms still work via the browser, but loaders/actions never run client-side, prefetching is dead, and there is no SPA navigation.
<LiveReload />
Connects to the dev server's reload socket. It only does anything during development — in production builds it no-ops, it does not throw. Ship it as-is; don't conditionally render it.
Vite plugin (default for new v2 apps): omit `<LiveReload />`. Vite's own HMR handles dev reload. This element is for the Classic Compiler only. If your project uses@remix-run/dev/viteinvite.config.ts, delete the import and element.
Loader, Action, and ErrorBoundary
Like any other route module, root.tsx can export loader, action, meta, links, headers, and ErrorBoundary. The root ErrorBoundary is the last line of defense — it catches errors thrown anywhere below it when no descendant boundary handles them.
// app/root.tsx
import { json } from "@remix-run/node";
import type { LoaderFunctionArgs } from "@remix-run/node";
import {
isRouteErrorResponse,
Links,
LiveReload,
Meta,
Scripts,
ScrollRestoration,
useRouteError,
} from "@remix-run/react";
export async function loader({ request }: LoaderFunctionArgs) {
return json({ user: await getUser(request) });
}
export function ErrorBoundary() {
const error = useRouteError();
return (
<html lang="en">
<head>
<title>Oops</title>
<Meta />
<Links />
</head>
<body>
{isRouteErrorResponse(error)
? <h1>{error.status} {error.statusText}</h1>
: <h1>Application Error</h1>}
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}Because the root ErrorBoundary replaces the entire document (not just the <Outlet /> slot), it must render its own <html>, <head>, <body>, and the document elements — otherwise the error page has no styles, no scripts, and no scroll restoration.
Layout Wrapper for <head> Reuse
To avoid duplicating the <html> shell between App and ErrorBoundary, extract a Layout component:
function Document({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
export default function App() {
return <Document><Outlet /></Document>;
}
export function ErrorBoundary() {
const error = useRouteError();
return <Document>{/* error UI */}</Document>;
}Vite plugin (default for new v2 apps): omit `<LiveReload />` from `Document`. Vite's own HMR handles dev reload; the element is Classic Compiler only.
Layout export (Remix v2 >= 2.4)
Remix 2.4 added a framework-level Layout export that wraps App, ErrorBoundary, and HydrateFallback automatically. This is the canonical way to share the document shell — Remix calls Layout(children) for each of those three entry points, so you don't have to hand-roll a Document wrapper.
// app/root.tsx
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
isRouteErrorResponse,
useRouteError,
} from "@remix-run/react";
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
export function ErrorBoundary() {
const error = useRouteError();
return isRouteErrorResponse(error)
? <h1>{error.status} {error.statusText}</h1>
: <h1>Application Error</h1>;
}App, ErrorBoundary, and HydrateFallback each render inside Layout, so none of them need their own <html>/<head>/<body>. This eliminates the duplication problem the hand-rolled Document wrapper above was solving.
The hand-rolled Document pattern in the previous section is the pre-2.4 / explicit alternative — still valid, but on Remix >= 2.4 prefer the Layout export. Reference: https://remix.run/docs/en/main/file-conventions/root.
Common Mistakes
- Missing `<Scripts />`: App renders but never hydrates — feels like SSR-only.
- `<ScrollRestoration />` after `<Scripts />`: Hydration order can clobber scroll restore. Put
<ScrollRestoration />first. - Conditionally rendering `<LiveReload />` (Classic Compiler): Unnecessary — it no-ops in production. Just ship it.
- Rendering `<LiveReload />` on Vite: Wrong direction. On Vite (default for new v2 apps), do NOT render `<LiveReload />`. Vite's own HMR handles dev reload; the Remix element is Classic Compiler only and is dead weight (or worse, a runtime warning) under Vite.
- `ErrorBoundary` without the document shell: Error page renders unstyled and unhydrated.
- Importing from `react-router-dom`: Use
@remix-run/react— the Remix re-export includes the loader/action wiring; the raw router package does not.
v1 → v2 Routing Migration
Remix v1 used a nested-folder convention where directories created URL segments and double-underscore folder names marked pathless layouts. v2 replaces that with a flat-file convention where dots in filenames create URL slashes. The grammars are not compatible — leftover v1 files in a v2 project silently produce wrong URLs.
Side-by-Side Convention Map
| Concept | v1 (nested folders) | v2 (flat routes) |
|---|---|---|
| Index route | index.tsx | _index.tsx |
| Pathless layout | __auth/ (double underscore) | _auth.tsx (single) |
| Nested URL | folder hierarchy | dot delimiter in filename |
| Dynamic segment | $param.tsx | $param.tsx (unchanged) |
| Splat | $.tsx | $.tsx (unchanged) |
| Escape literal | n/a | [.], [] brackets |
| Opt-out of layout | move out of folder | trailing _ (foo_.bar.tsx) |
| Co-locate non-routes | adjacent files in folder | feature/route.tsx + siblings |
The Two Silent-Break Cases
These are the most common upgrade failures because the file still compiles, the dev server still starts, and the URL still resolves — just to something other than what v1 produced.
1. index.tsx → _index.tsx
In v1, index.tsx rendered at the parent path. In v2, index.tsx is read as a literal segment and renders at /index. Rename every index file:
v1 v2
app/routes/index.tsx app/routes/_index.tsx
app/routes/concerts/index.tsx app/routes/concerts._index.tsx
app/routes/__auth/login.tsx app/routes/_auth.login.tsx2. __double underscore → _single underscore
v1 pathless layouts used a folder named __auth (with the folder itself containing routes). v2 uses a file named _auth.tsx (the parent module) plus dot-delimited children:
v1 v2
app/routes/__auth/login.tsx app/routes/_auth.tsx
app/routes/__auth/signup.tsx app/routes/_auth.login.tsx
app/routes/_auth.signup.tsxA v1 __auth folder copied verbatim into v2 produces the literal URL /__auth/login.
Folder-Hierarchy → Dot-Delimited
v1 v2
app/routes/concerts/index.tsx app/routes/concerts._index.tsx
app/routes/concerts/$city.tsx app/routes/concerts.$city.tsx
app/routes/concerts/trending.tsx app/routes/concerts.trending.tsxIf you prefer folders for organization, v2 supports them via the folder + `route.tsx` pattern. Only route.tsx becomes a route; siblings are colocated source:
app/routes/
concerts/
route.tsx ← becomes /concerts
queries.server.ts ← NOT a route
Chart.tsx ← NOT a routeWithout a route.tsx inside, the folder is ignored entirely.
v1 Compatibility Adapter: @remix-run/v1-route-convention
If a v1 route tree is large and you need to ship before migrating, install the official fallback adapter:
npm install -D @remix-run/v1-route-conventionWire it in remix.config.js:
// remix.config.js
import { createRoutesFromFolders } from "@remix-run/v1-route-convention";
/** @type {import('@remix-run/dev').AppConfig} */
export default {
ignoredRouteFiles: ["**/.*"],
routes(defineRoutes) {
return createRoutesFromFolders(defineRoutes, {
ignoredFilePatterns: ["**/.*", "**/*.css"],
});
},
};Vite-based v2 projects use vite.config.ts with the Remix Vite plugin instead of remix.config.js.
The adapter preserves v1 nested-folder behavior — index.tsx, __double underscores, and folder-as-URL all keep working. Treat it as a migration aid, not a long-term answer: new code should adopt v2 conventions.
ignoredRouteFiles for Non-Route Files
The flat structure means everything under app/routes/ is a route candidate. CSS, server helpers, and test files dropped in there get treated as routes and surface as build warnings or 404-shaped routes. Two options:
Option 1 — Folder convention: move helpers into a feature/ folder; only route.tsx becomes a route, siblings are inert.
Option 2 — `ignoredRouteFiles` in remix.config.js:
// remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
export default {
ignoredRouteFiles: [
"**/.*", // dotfiles
"**/*.css", // stylesheets
"**/*.test.{ts,tsx}", // test files
"**/*.server.{ts,tsx}", // server-only modules
],
};Vite-based v2 projects use vite.config.ts with the Remix Vite plugin instead of remix.config.js.
Globs that match are skipped during route discovery.
Manual routes() for Programmatic Definitions
You can also define routes programmatically. The callback receives defineRoutes and runs alongside filesystem routes — it does not replace them. Use ignoredRouteFiles if you want the manual definitions to be authoritative:
// remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
export default {
ignoredRouteFiles: ["**/*"], // ignore filesystem entirely
routes(defineRoutes) {
return defineRoutes((route) => {
route("/", "home.tsx", { index: true });
route("/concerts/:city", "concerts/city.tsx");
});
},
};Vite-based v2 projects use vite.config.ts with the Remix Vite plugin instead of remix.config.js.
Migration Checklist
1. Rename every index.tsx → _index.tsx (in folders, also flatten with dots). 2. Convert every __name/ folder → _name.tsx parent + dotted children. 3. Flatten folder hierarchies to dot-delimited filenames (or use folder + route.tsx). 4. Audit imports: every react-router-dom import must become @remix-run/react. 5. Add ignoredRouteFiles for any non-route files left under app/routes/. 6. If you can't migrate everything at once, install @remix-run/v1-route-convention and wire it into remix.config.js as a temporary fallback.