All v2 APIs and behaviors are available in v1 with Future Flags. They can be enabled one at a time to avoid development disruption of your project. After you have enabled all flags, upgrading to v2 should be a non-breaking upgrade.
You can keep using the old convention with @remix-run/v1-route-convention
even after upgrading to v2 if you don't want to make the change right now (or ever, it's just a convention and you can use whatever file organization you prefer).
npm i @remix-run/v1-route-convention
const {
createRoutesFromFolders,
} = require("@remix-run/v1-route-convention");
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
future: {
// makes the warning go away in v1.15
v2_routeConvention: true,
},
routes(defineRoutes) {
// uses the v1 convention, works in v1.15+ and v2
return createRoutesFromFolders(defineRoutes);
},
};
.
) in file names instead of folder nestingsuffixed_
underscores in segments opt-out of nesting with a potentially matching parent route instead of dots (.
)._prefixed
underscores in segments create layout routes without a path instead of a __double
underscore prefix._index.tsx
files create index routes instead of index.tsx
A routes folder that looks like this in v1:
routes
├── __auth
│ ├── login.tsx
│ ├── logout.tsx
│ └── signup.tsx
├── __public
│ ├── about-us.tsx
│ ├── contact.tsx
│ └── index.tsx
├── dashboard
│ ├── calendar
│ │ ├── $day.tsx
│ │ └── index.tsx
│ ├── projects
│ │ ├── $projectId
│ │ │ ├── collaborators.tsx
│ │ │ ├── edit.tsx
│ │ │ ├── index.tsx
│ │ │ ├── settings.tsx
│ │ │ └── tasks.$taskId.tsx
│ │ ├── $projectId.tsx
│ │ └── new.tsx
│ ├── calendar.tsx
│ ├── index.tsx
│ └── projects.tsx
├── __auth.tsx
├── __public.tsx
└── dashboard.calendar.$projectId.print.tsx
Becomes this with v2_routeConvention
:
routes
├── _auth.login.tsx
├── _auth.logout.tsx
├── _auth.signup.tsx
├── _auth.tsx
├── _public._index.tsx
├── _public.about-us.tsx
├── _public.contact.tsx
├── _public.tsx
├── dashboard._index.tsx
├── dashboard.calendar._index.tsx
├── dashboard.calendar.$day.tsx
├── dashboard.calendar.tsx
├── dashboard.projects.$projectId._index.tsx
├── dashboard.projects.$projectId.collaborators.tsx
├── dashboard.projects.$projectId.edit.tsx
├── dashboard.projects.$projectId.settings.tsx
├── dashboard.projects.$projectId.tasks.$taskId.tsx
├── dashboard.projects.$projectId.tsx
├── dashboard.projects.new.tsx
├── dashboard.projects.tsx
└── dashboard_.calendar.$projectId.print.tsx
Note that parent routes are now grouped together instead of having dozens of routes between them (like the auth routes). Routes with the same path but not the same nesting (like dashboard
and dashboard_
) also group together.
With the new convention, any route can be a directory with a route.tsx
file inside to define the route module. This enables co-location of modules with the route they're used in:
For example, we can move _public.tsx
to _public/route.tsx
and then co-locate modules the route uses:
routes
├── _auth.tsx
├── _public
│ ├── footer.tsx
│ ├── header.tsx
│ └── route.tsx
├── _public._index.tsx
├── _public.about-us.tsx
└── etc.
For more background on this change, see the original "flat routes" proposal.
meta
In Remix v2, the signature for route meta
functions and how Remix handles meta tags under the hood have changed.
Instead of returning an object from meta
, you will now return an array of descriptors and manage the merge yourself. This brings the meta
API closer to links
, and it allows for more flexibility and control over how meta tags are rendered.
In addition, <Meta />
will no longer render meta for every route in the hierarchy. Only data returned from meta
in the leaf route will be rendered. You can still choose to include meta from the parent route by accessing matches
in the function's arguments.
For more background on this change, see the original v2 meta
proposal.
meta
conventions in v2You can update your meta
exports with the @remix-run/v1-meta
package to continue using v1 conventions.
Using the metaV1
function, you can pass in the meta
function's arguments and the same object it currently returns. This function will use the same merging logic to merge the leaf route's meta with its direct parent route meta before converting it to an array of meta descriptors usable in v2.
export function meta() {
return {
title: "...",
description: "...",
"og:title": "...",
};
}
import { metaV1 } from "@remix-run/v1-meta";
export function meta(args) {
return metaV1(args, {
title: "...",
description: "...",
"og:title": "...",
});
}
It's important to note that this function will not merge meta across the entire hierarchy by default. This is because you may have some routes that return an array of objects directly without the metaV1
function and this could result in unpredictable behavior. If you want to merge meta across the entire hierarchy, use the metaV1
function for all of your route's meta exports.
parentsData
argumentIn v2, the meta
function no longer receives the parentsData
argument. This is because meta
now has access to all of your route matches via the matches
argument, which includes loader data for each match.
To replicate the API of parentsData
, the @remix-run/v1-meta
package provides a getMatchesData
function. It returns an object where the data for each match is keyed by the route's ID.
export function meta(args) {
const parentData = args.parentsData["routes/parent"];
}
Becomes:
import { getMatchesData } from "@remix-run/v1-meta";
export function meta(args) {
const matchesData = getMatchesData(args);
const parentData = matchesData["routes/parent"];
}
meta
export function meta() {
return {
title: "...",
description: "...",
"og:title": "...",
};
}
export function meta() {
return [
{ title: "..." },
{ name: "description", content: "..." },
{ property: "og:title", content: "..." },
// you can now add SEO related <links>
{ tagName: "link", rel: "canonical", href: "..." },
// and <script type=ld+json>
{
"script:ld+json": {
some: "value",
},
},
];
}
matches
argumentNote that in v1 the objects returned from nested routes were all merged, you will need to manage the merge yourself now with matches
:
export function meta({ matches }) {
const rootMeta = matches[0].meta;
const title = rootMeta.find((m) => m.title);
return [
title,
{ name: "description", content: "..." },
{ property: "og:title", content: "..." },
// you can now add SEO related <links>
{ tagName: "link", rel: "canonical", href: "..." },
// and <script type=ld+json>
{
"script:ld+json": {
"@context": "https://schema.org",
"@type": "Organization",
name: "Remix",
},
},
];
}
The meta v2 docs have more tips on merging route meta.
CatchBoundary
and ErrorBoundary
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
future: {
v2_errorBoundary: true,
},
};
In v1, a thrown Response
will render the closest CatchBoundary
while all other unhandled exceptions render the ErrorBoundary
. In v2 there is no CatchBoundary
and all unhandled exceptions will render the ErrorBoundary
, response or otherwise.
Additionally, the error is no longer passed to ErrorBoundary
as props but is accessed with the useRouteError
hook.
import { useCatch } from "@remix-run/react";
export function CatchBoundary() {
const caught = useCatch();
return (
<div>
<h1>Oops</h1>
<p>Status: {caught.status}</p>
<p>{caught.data.message}</p>
</div>
);
}
export function ErrorBoundary({ error }) {
console.error(error);
return (
<div>
<h1>Uh oh ...</h1>
<p>Something went wrong</p>
<pre>{error.message || "Unknown error"}</pre>
</div>
);
}
Becomes:
import {
useRouteError,
isRouteErrorResponse,
} from "@remix-run/react";
export function ErrorBoundary() {
const error = useRouteError();
// when true, this is what used to go to `CatchBoundary`
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>Oops</h1>
<p>Status: {error.status}</p>
<p>{error.data.message}</p>
</div>
);
}
// Don't forget to typecheck with your own logic.
// Any value can be thrown, not just errors!
let errorMessage = "Unknown error";
if (isDefinitelyAnError(error)) {
errorMessage = error.message;
}
return (
<div>
<h1>Uh oh ...</h1>
<p>Something went wrong.</p>
<pre>{errorMessage}</pre>
</div>
);
}
useTransition
This hook is now called useNavigation
to avoid confusion with the recent React hook by the same name. It also no longer has the type
field and flattens the submission
object into the navigation
object itself.
import { useTransition } from "@remix-run/react";
function SomeComponent() {
const transition = useTransition();
transition.submission.formData;
transition.submission.formMethod;
transition.submission.formAction;
transition.type;
}
import { useNavigation } from "@remix-run/react";
function SomeComponent() {
const navigation = useNavigation();
// transition.submission keys are flattened onto `navigation[key]`
navigation.formData;
navigation.formMethod;
navigation.formAction;
// this key is removed
navigation.type;
}
You can derive the previous transition.type
with the following examples. Keep in mind, there's probably a simpler way to get the same behavior, usually checking navigation.state
, navigation.formData
or the data returned from an action with useActionData
can get the UX you're looking for. Feel free to ask us in Discord and we'll help you out :D
function Component() {
const navigation = useNavigation();
// transition.type === "actionSubmission"
const isActionSubmission =
navigation.state === "submitting";
// transition.type === "actionReload"
const isActionReload =
navigation.state === "loading" &&
navigation.formMethod != null &&
navigation.formMethod != "get" &&
// We had a submission navigation and are loading the submitted location
navigation.formAction === navigation.pathname;
// transition.type === "actionRedirect"
const isActionRedirect =
navigation.state === "loading" &&
navigation.formMethod != null &&
navigation.formMethod != "get" &&
// We had a submission navigation and are now navigating to different location
navigation.formAction !== navigation.pathname;
// transition.type === "loaderSubmission"
const isLoaderSubmission =
navigation.state === "loading" &&
navigation.state.formMethod === "get" &&
// We had a loader submission and are navigating to the submitted location
navigation.formAction === navigation.pathname;
// transition.type === "loaderSubmissionRedirect"
const isLoaderSubmissionRedirect =
navigation.state === "loading" &&
navigation.state.formMethod === "get" &&
// We had a loader submission and are navigating to a new location
navigation.formAction !== navigation.pathname;
}
useFetcher
Like useNavigation
, useFetcher
has flattened the submission
and removed the type
field.
import { useFetcher } from "@remix-run/react";
function SomeComponent() {
const fetcher = useFetcher();
fetcher.submission.formData;
fetcher.submission.formMethod;
fetcher.submission.formAction;
fetcher.type;
}
import { useFetcher } from "@remix-run/react";
function SomeComponent() {
const fetcher = useFetcher();
// these keys are flattened
fetcher.formData;
fetcher.formMethod;
fetcher.formAction;
// this key is removed
fetcher.type;
}
You can derive the previous fetcher.type
with the following examples. Keep in mind, there's probably a simpler way to get the same behavior, usually checking fetcher.state
, fetcher.formData
or the data returned from an action on fetcher.data
can get the UX you're looking for. Feel free to ask us in Discord and we'll help you out :D
function Component() {
const fetcher = useFetcher();
// fetcher.type === "done"
const isDone =
fetcher.state === "idle" && fetcher.data != null;
// fetcher.type === "actionSubmission"
const isActionSubmission = fetcher.state === "submitting";
// fetcher.type === "actionReload"
const isActionReload =
fetcher.state === "loading" &&
fetcher.formMethod != null &&
fetcher.formMethod != "get" &&
// If we returned data, we must be reloading
fetcher.data != null;
// fetcher.type === "actionRedirect"
const isActionRedirect =
fetcher.state === "loading" &&
fetcher.formMethod != null &&
navigation.formMethod != "get" &&
// If we have no data we must have redirected
fetcher.data == null;
// fetcher.type === "loaderSubmission"
const isLoaderSubmission =
navigation.state === "loading" &&
navigation.state.formMethod === "get";
// fetcher.type === "normalLoad"
const isNormalLoad =
navigation.state === "loading" &&
navigation.state.formMethod == null;
}
formMethod
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
future: {
v2_normalizeFormMethod: true,
},
};
Multiple APIs return the formMethod
of a submission. In v1 they return a lowercase version of the method but in v2 they return the UPPERCASE version. This is to bring it in line with HTTP and fetch
specifications.
function Something() {
const navigation = useNavigation();
// v1
navigation.formMethod === "post";
// v2
navigation.formMethod === "POST";
}
export function shouldRevalidate({ formMethod }) {
// v1
formMethod === "post";
// v2
formMethod === "POST";
}
imagesizes
and imagesrcset
Route links
properties should all be the React camelCase values instead of HTML lowercase values. These two values snuck in as lowercase in v1. In v2 only the camelCase versions will be valid:
export const links: LinksFunction = () => {
return [
{
rel: "preload",
as: "image",
imagesrcset: "...",
imagesizes: "...",
},
];
};
export const links: V2_LinksFunction = () => {
return [
{
rel: "preload",
as: "image",
imageSrcSet: "...",
imageSizes: "...",
},
];
};
browserBuildDirectory
In your remix.config.js
, rename browserBuildDirectory
to assetsBuildDirectory
.
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
browserBuildDirectory: "./public/build",
};
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
assetsBuildDirectory: "./public/build",
};
serverBuildDirectory
In your remix.config.js
, rename serverBuildDirectory
to serverBuildPath
and specify a module path, not a directory.
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
serverBuildDirectory: "./build",
};
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
serverBuildPath: "./build/index.js",
};
Remix used to create more than a single module for the server but it now creates a single file.
serverBuildTarget
Instead of specifying a build target, use the Remix Config options to generate the server build your server target expects. This change allows Remix to deploy to more JavaScript runtimes, servers, and hosts without Remix source code needing to know about them.
The following configurations should replace your current serverBuildTarget
:
arc
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
publicPath: "/_static/build/",
serverBuildPath: "server/index.js",
serverMainFields: ["main", "module"], // default value, can be removed
serverMinify: false, // default value, can be removed
serverModuleFormat: "cjs", // default value, can be removed
serverPlatform: "node", // default value, can be removed
};
cloudflare-pages
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
publicPath: "/build/", // default value, can be removed
serverBuildPath: "functions/[[path]].js",
serverConditions: ["worker"],
serverDependenciesToBundle: "all",
serverMainFields: ["browser", "module", "main"],
serverMinify: true,
serverModuleFormat: "esm",
serverPlatform: "neutral",
};
cloudflare-workers
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
publicPath: "/build/", // default value, can be removed
serverBuildPath: "build/index.js", // default value, can be removed
serverConditions: ["worker"],
serverDependenciesToBundle: "all",
serverMainFields: ["browser", "module", "main"],
serverMinify: true,
serverModuleFormat: "esm",
serverPlatform: "neutral",
};
deno
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
publicPath: "/build/", // default value, can be removed
serverBuildPath: "build/index.js", // default value, can be removed
serverConditions: ["deno", "worker"],
serverDependenciesToBundle: "all",
serverMainFields: ["module", "main"],
serverMinify: false, // default value, can be removed
serverModuleFormat: "esm",
serverPlatform: "neutral",
};
netlify
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
publicPath: "/build/", // default value, can be removed
serverBuildPath: ".netlify/functions-internal/server.js",
serverMainFields: ["main", "module"], // default value, can be removed
serverMinify: false, // default value, can be removed
serverModuleFormat: "cjs", // default value, can be removed
serverPlatform: "node", // default value, can be removed
};
node-cjs
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
publicPath: "/build/", // default value, can be removed
serverBuildPath: "build/index.js", // default value, can be removed
serverMainFields: ["main", "module"], // default value, can be removed
serverMinify: false, // default value, can be removed
serverModuleFormat: "cjs", // default value, can be removed
serverPlatform: "node", // default value, can be removed
};
vercel
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
publicPath: "/build/", // default value, can be removed
serverBuildPath: "api/index.js",
serverMainFields: ["main", "module"], // default value, can be removed
serverMinify: false, // default value, can be removed
serverModuleFormat: "cjs", // default value, can be removed
serverPlatform: "node", // default value, can be removed
};
serverModuleFormat
The default server module output format will be changing from cjs
to esm
.
In your remix.config.js
, you should specify either serverModuleFormat: "cjs"
to retain existing behavior, or serverModuleFormat: "esm"
, to opt into the future behavior.
We are still stabilizing the new dev server that enables HMR, several CSS libraries (CSS Modules, Vanilla Extract, Tailwind, PostCSS) and simplifies integration with various servers.
We expect a stable v2_dev
flag in Remix v1.16.0
. Once that ships, we'll have more instructions here to prepare your app for v2.