Vite is a powerful, performant and extensible development environment for JavaScript projects. In order to improve and extend Remix's bundling capabilities, we're currently exploring the use of Vite as an alternative compiler to esbuild.
Legend: β (Tested),β (Untested), β³ (Not Yet Supported)
Feature | Node | Deno | Cloudflare | Notes |
---|---|---|---|---|
Built-in dev server | β | β | β³ | |
Other servers (e.g. Express) | β | β | β³ | |
HMR | β | β | β³ | |
HDR | β | β | β³ | |
MDX routes | β | β | β³ | Supported with some deprecations. |
To get started with a minimal server, you can use the unstable-vite
template:
npx create-remix@latest --template remix-run/remix/templates/unstable-vite
If you'd rather customize your server, you can use the unstable-vite-express
template:
npx create-remix@latest --template remix-run/remix/templates/unstable-vite-express
These templates include a vite.config.ts
file which is where the Remix Vite plugin is configured.
The Vite plugin does not use remix.config.js
. Instead, the plugin directly accepts the following subset of Remix config options:
For example, to configure ignoredRouteFiles
:
import { unstable_vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
remix({
ignoredRouteFiles: ["**/.*"],
}),
],
});
All other bundling-related options are now configured with Vite. This means you have much greater control over the bundling process.
There is a notable difference with the way Vite manages the public
directory compared to the existing Remix compiler. During the build, Vite copies files from the public
directory into build/client
, whereas the Remix compiler left the public
directory untouched and used a subdirectory (public/build
) as the client build directory.
In order to align the default Remix project structure with the way Vite works, the build output paths have been changed.
build/server
by default.build/client
by default.This means that the following configuration defaults have been changed:
"build/client"
rather than "public/build"
"/"
rather than "/build/"
"build/server/index.js"
rather than "build/index.js"
One of the reasons that Remix is moving to Vite is, so you have less to learn when adopting Remix. This means that, for any additional bundling features you'd like to use, you should reference Vite documentation and the Vite plugin community rather than the Remix documentation.
Vite has many features and plugins that are not built into the existing Remix compiler. The use of any such features will render the existing Remix compiler unable to compile your app, so only use them if you intend to use Vite exclusively from here on out.
.server
directoriesIn addition to .server
files, the Remix's Vite plugin also supports .server
directories.
Any code in a .server
directory will be excluded from the client bundle.
app
βββ .server π everything in this directory is excluded from the client bundle
β βββ auth.ts
β βββ db.ts
βββ cms.server.ts π everything in this file is excluded from the client bundle
βββ root.tsx
βββ routes
βββ _index.tsx
.server
files and directories can be anywhere within your Remix app directory (typically app/
).
If you need more control, you can always write your own Vite plugins to exclude other files or directories from any other locations.
π Install Vite as a development dependency
npm install -D vite
Remix is now just a Vite plugin, so you'll need to hook it up to Vite.
π Replace remix.config.js
with vite.config.ts
at the root of your Remix app
import { unstable_vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [remix()],
});
The subset of supported Remix config options should be passed directly to the plugin:
export default defineConfig({
plugins: [
remix({
ignoredRouteFiles: ["**/.*"],
}),
],
});
Vite handles imports for all sorts of different file types, sometimes in ways that differ from the existing Remix compiler, so let's reference Vite's types from vite/client
instead of the obsolete types from @remix-run/dev
.
π Replace your remix.env.d.ts
with a new env.d.ts
file
/// <reference types="@remix-run/node" />
/// <reference types="vite/client" />
π Replace reference to remix.env.d.ts
in tsconfig.json
- "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
+ "include": ["env.d.ts", "**/*.ts", "**/*.tsx"],
If you were using remix-serve
in development (or remix dev
without the -c
flag), you'll need to switch to the new minimal dev server.
It comes built-in with the Remix Vite plugin and will take over when you run remix vite:dev
.
Unlike remix-serve
, the Remix Vite plugin doesn't install any global Node polyfills so you'll need to install them yourself if you were relying on them. The easiest way to do this is by calling installGlobals
at the top of your Vite config.
You'll also need to update to the new build output paths, which are build/server
for the server and build/client
for client assets.
π Update your dev
, build
and start
scripts
{
"scripts": {
"dev": "remix vite:dev",
"build": "remix vite:build",
"start": "remix-serve ./build/server/index.js"
}
}
π Install global Node polyfills in your Vite config
import { unstable_vitePlugin as remix } from "@remix-run/dev";
+import { installGlobals } from "@remix-run/node";
import { defineConfig } from "vite";
+installGlobals();
export default defineConfig({
plugins: [remix()],
});
If you were using a custom server in development, you'll need to edit your custom server to use Vite's connect
middleware.
This will delegate asset requests and initial render requests to Vite during development, letting you benefit from Vite's excellent DX even with a custom server.
You'll also need to update your server code to reference the new build output paths, which are build/server
for the server build and build/client
for client assets.
Remix exposes the server build's module ID so that it can be loaded dynamically in your request handler during development via vite.ssrLoadModule
.
import { unstable_viteServerBuildModuleId } from "@remix-run/dev";
For example, if you were using Express, here's how you could do it.
π Update your server.mjs
file
import { unstable_viteServerBuildModuleId } from "@remix-run/dev";
import { createRequestHandler } from "@remix-run/express";
import { installGlobals } from "@remix-run/node";
import express from "express";
installGlobals();
const vite =
process.env.NODE_ENV === "production"
? undefined
: await import("vite").then(({ createServer }) =>
createServer({
server: {
middlewareMode: true,
},
})
);
const app = express();
// handle asset requests
if (vite) {
app.use(vite.middlewares);
} else {
app.use(
"/assets",
express.static("build/client/assets", {
immutable: true,
maxAge: "1y",
})
);
}
app.use(express.static("build/client", { maxAge: "1h" }));
// handle SSR requests
app.all(
"*",
createRequestHandler({
build: vite
? () =>
vite.ssrLoadModule(
unstable_viteServerBuildModuleId
)
: await import("./build/server/index.js"),
})
);
const port = 3000;
app.listen(port, () =>
console.log("http://localhost:" + port)
);
π Update your build
, dev
, and start
scripts
{
"scripts": {
"dev": "node ./server.mjs",
"build": "remix vite:build",
"start": "cross-env NODE_ENV=production node ./server.mjs"
}
}
If you prefer, you can instead author your custom server in TypeScript.
You could then use tools like tsx
or tsm
to run your custom server:
tsx ./server.tsx
node --loader tsm ./server.ts
Just remember that there might be some noticeable slowdown for initial server startup if you do this.
When using the existing Remix compiler's default options, the server was compiled into build
and the client was compiled into public/build
. Due to differences with the way Vite typically works with its public
directory compared to the existing Remix compiler, these output paths have changed.
π Update references to build output paths
build/server
by default.build/client
by default.For example, to update the Dockerfile from the Blues Stack:
-COPY --from=build /myapp/build /myapp/build
-COPY --from=build /myapp/public /myapp/public
+COPY --from=build /myapp/server/build /myapp/server/build
+COPY --from=build /myapp/client/build /myapp/client/build
The Remix compiler leverages the paths
option in your tsconfig.json
to resolve path aliases. This is commonly used in the Remix community to define ~
as an alias for the app
directory.
Vite does not provide any path aliases by default. If you were relying on this feature, you can install the vite-tsconfig-paths plugin to automatically resolve path aliases from your tsconfig.json
in Vite, matching the behavior of the Remix compiler:
π Install vite-tsconfig-paths
npm install -D vite-tsconfig-paths
π Add vite-tsconfig-paths
to your Vite config
import { unstable_vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [remix(), tsconfigPaths()],
});
@remix-run/css-bundle
Vite has built-in support for CSS side effect imports, PostCSS and CSS Modules, among other CSS bundling features. The Remix Vite plugin automatically attaches bundled CSS to the relevant routes.
The @remix-run/css-bundle
cssBundleHref
export will always be undefined
.
π Uninstall @remix-run/css-bundle
npm uninstall @remix-run/css-bundle
π Remove references to cssBundleHref
- import { cssBundleHref } from "@remix-run/css-bundle";
import type { LinksFunction } from "@remix-run/node"; // or cloudflare/deno
export const links: LinksFunction = () => [
- ...(cssBundleHref
- ? [{ rel: "stylesheet", href: cssBundleHref }]
- : []),
// ...
];
If a route's links
function is only used to wire up cssBundleHref
, you can remove it entirely.
- import { cssBundleHref } from "@remix-run/css-bundle";
- import type { LinksFunction } from "@remix-run/node"; // or cloudflare/deno
- export const links: LinksFunction = () => [
- ...(cssBundleHref
- ? [{ rel: "stylesheet", href: cssBundleHref }]
- : []),
- ];
In Vite, CSS files are typically imported as side effects.
During development, Vite injects imported CSS files into the page via JavaScript, and the Remix Vite plugin will inline imported CSS alongside your link tags to avoid a flash of unstyled content. In the production build, the Remix Vite plugin will automatically attach CSS files to the relevant routes.
This also means that in many cases you won't need the links
function export anymore.
π Convert CSS imports to side effects
// No need to export a links function anymore:
- import type { LinksFunction } from "@remix-run/node"; // or cloudflare/deno
- import dashboardStyles from "./dashboard.css?url";
- export const links: LinksFunction = () => [
- { rel: "stylesheet", href: dashboardStyles },
- ];
// Just import the CSS as a side effect:
+ import "./dashboard.css";
?url
query string, which in theory would match the behavior of the existing Remix compiler when used for CSS files, there is a known Vite issue with ?url
for CSS imports. This may be fixed in the future, but in the meantime you should exclusively use side effect imports for CSS.
If you were using Remix's regular CSS support, one important caveat to be aware of is that these styles will no longer be mounted and unmounted automatically when navigating between routes during development.
As a result, you may be more likely to encounter CSS collisions. If this is a concern, you might want to consider migrating your regular CSS files to CSS Modules or using a naming convention that prefixes class names with the corresponding file name.
If your project is using Tailwind CSS, you'll first need to ensure that you have a PostCSS config file which will get automatically picked up by Vite.
This is because the Remix compiler didn't require a PostCSS config file when Remix's tailwind
option was enabled.
π Add PostCSS config if it's missing, including the tailwindcss
plugin
export default {
plugins: {
tailwindcss: {},
},
};
If your project already has a PostCSS config file, you'll need to add the tailwindcss
plugin if it's not already present.
This is because the Remix compiler included this plugin automatically when Remix's tailwind
config option was enabled.
π Add the tailwindcss
plugin to your PostCSS config if it's missing
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
π Convert Tailwind CSS import to a side effect
If you haven't already, be sure to convert your CSS imports to side effects.
// Don't export as a link descriptor:
- import type { LinksFunction } from "@remix-run/node"; // or cloudflare/deno
- import tailwind from "./tailwind.css";
- export const links: LinksFunction = () => [
- { rel: "stylesheet", href: tailwind },
- ];
// Import as a side effect instead:
+ import "./tailwind.css";
If you're using Vanilla Extract, you'll need to set up the Vite plugin.
π Install the official Vanilla Extract plugin for Vite
npm install -D @vanilla-extract/vite-plugin
π Add the Vanilla Extract plugin to your Vite config
import { unstable_vitePlugin as remix } from "@remix-run/dev";
import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [remix(), vanillaExtractPlugin()],
});
If you're using MDX, since Vite's plugin API is an extension of the Rollup plugin API, you should use the official MDX Rollup plugin:
π Install the MDX Rollup plugin
npm install -D @mdx-js/rollup
π Add the MDX Rollup plugin to your Vite config
import mdx from "@mdx-js/rollup";
import { unstable_vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [remix(), mdx()],
});
The Remix compiler allowed you to define frontmatter in MDX. If you were using this feature, you can achieve this in Vite using remark-mdx-frontmatter.
π Install the required Remark frontmatter plugins
npm install -D remark-frontmatter remark-mdx-frontmatter
π Pass the Remark frontmatter plugins to the MDX Rollup plugin
import mdx from "@mdx-js/rollup";
import { unstable_vitePlugin as remix } from "@remix-run/dev";
import remarkFrontmatter from "remark-frontmatter";
import remarkMdxFrontmatter from "remark-mdx-frontmatter";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
remix(),
mdx({
remarkPlugins: [
remarkFrontmatter,
remarkMdxFrontmatter,
],
}),
],
});
In the Remix compiler, the frontmatter export was named attributes
. This differs from the frontmatter plugin's default export name of frontmatter
. Although it's possible to configure the frontmatter export name, we recommend updating your app code to use the default export name instead.
π Rename MDX attributes
export to frontmatter
within MDX files
---
title: Hello, World!
---
- # {attributes.title}
+ # {frontmatter.title}
π Rename MDX attributes
export to frontmatter
for consumers
import Component, {
- attributes,
+ frontmatter,
} from "./posts/first-post.mdx";
π Add types for *.mdx
files to env.d.ts
/// <reference types="@remix-run/node" />
/// <reference types="vite/client" />
declare module "*.mdx" {
let MDXComponent: (props: any) => JSX.Element;
export const frontmatter: any;
export default MDXComponent;
}
The Remix compiler allowed you to define headers
, meta
and handle
route exports in your frontmatter. This Remix-specific feature is obviously not supported by the remark-mdx-frontmatter
plugin. If you were using this feature, you should manually map frontmatter to route exports yourself:
π Map frontmatter to route exports for MDX routes
---
meta:
- title: My First Post
- name: description
content: Isn't this awesome?
headers:
Cache-Control: no-cache
---
export const meta = frontmatter.meta;
export const headers = frontmatter.headers;
# Hello World
Note that, since you're explicitly mapping MDX route exports, you're now free to use whatever frontmatter structure you like.
---
title: My First Post
description: Isn't this awesome?
---
export const meta = () => {
return [
{ title: frontmatter.title },
{
name: "description",
content: frontmatter.description,
},
];
};
# Hello World
The Remix compiler also provided a filename
export from all MDX files. This was primarily designed to enable linking to collections of MDX routes. If you were using this feature, you can achieve this in Vite via glob imports which give you a handy data structure that maps file names to modules. This makes it much easier to maintain a list of MDX files since you no longer need to import each one manually.
For example, to import all MDX files in the posts
directory:
const posts = import.meta.glob("./posts/*.mdx");
This is equivalent to writing this by hand:
const posts = {
"./posts/a.mdx": () => import("./posts/a.mdx"),
"./posts/b.mdx": () => import("./posts/b.mdx"),
"./posts/c.mdx": () => import("./posts/c.mdx"),
// etc.
};
You can also eagerly import all MDX files if you'd prefer:
const posts = import.meta.glob("./posts/*.mdx", {
eager: true,
});
With Vite, Remix gets stricter about which exports are allowed from your route modules.
Previously, Remix allowed user-defined exports from routes. The Remix compiler would then rely on treeshaking to remove any code only intended for use on the server from the client bundle.
// `loader`: always server-only, remove from client bundle π
export const loader = () => {};
// `default`: always client-safe, keep `default` in client bundle π
export default function SuperCool() {}
// User-defined export
export const mySuperCoolThing = () => {
/*
Client-safe or server-only? Depends on what code is in here... π€·
Rely on treeshaking to remove from client bundle if it depends on server-only code.
*/
};
In contrast, Vite processes each module in isolation during development, so relying on cross-module treeshaking is not an option.
For most modules, you should already be using .server
files or directories to isolate server-only code.
But routes are a special case since they intentionally blend client and server code.
Remix knows that exports like loader
, action
, headers
, etc. are server-only, so it can safely remove them from the client bundle.
But there's no way to know when looking at a single route module in isolation whether user-defined exports are server-only.
That's why Remix's Vite plugin is stricter about which exports are allowed from your route modules.
export const loader = () => {}; // server-only π
export default function SuperCool() {} // client-safe π
// Need to decide whether this is client-safe or server-only without any other information π¬
export const mySuperCoolThing = () => {};
In fact, we'd rather not rely on treeshaking for correctness at all. If tomorrow you or your coworker accidentally imports something you thought was client-safe, treeshaking will no longer exclude that from your client bundle and you might end up with server code in your app! Treeshaking is designed as a pure optimization, so relying on it for correctness is brittle.
So instead of treeshaking, its better to be explicit about what code is client-safe and what code is server-only.
For route modules, that means only exporting Remix route exports.
For anything else, put it in a separate module and use a .server
file or directory when needed.
Ultimately, Route exports are Remix API. Think of a Remix route module like a function and the exports like named arguments to the function.
// Not real API, just a mental model
const route = createRoute({ loader, mySuperCoolThing });
// ^^^^^^^^^^^^^^^^
// Object literal may only specify known properties, and 'mySuperCoolThing' does not exist in type 'RemixRoute'
Just like how you shouldn't pass unexpected named arguments to a function, you shouldn't create unexpected exports from a route module. The result is that Remix is simpler and more predictable. In short, Vite made us eat our veggies, but turns out they were delicious all along!
π Move any user-defined route exports to a separate module
For example, here's a route with a user-defined export called mySuperCoolThing
:
// β
This is a valid Remix route export, so it's fine
export const loader = () => {};
// β
This is also a valid Remix route export
export default function SuperCool() {}
// β This isn't a Remix-specific route export, just something I made up
export const mySuperCoolThing = () => {};
One option is to colocate your route and related utilities in the same directory if your routing convention allows it. For example, with the default route convention in v2:
export const loader = () => {};
export default function SuperCool() {}
// If this was server-only code, I'd rename this file to "utils.server.ts"
export const mySuperCoolThing = () => {};
Check out the known issues with the Remix Vite plugin on GitHub before filing a new bug report!
If you are expecting hot updates but getting full page reloads, check out our discussion on Hot Module Replacement to learn more about the limitations of React Fast Refresh and workarounds for common issues.
Vite supports both ESM and CJS dependencies, but sometimes you might still run into issues with ESM / CJS interop. Usually, this is because a dependency is not properly configured to support ESM. And we don't blame them, its really tricky to support both ESM and CJS properly.
You can check Are The Types Wrong to see if the dependency giving you trouble might be misconfigured.
Additionally, you can use the vite-plugin-cjs-interop plugin smooth over issues with default
exports for external CJS dependencies.
Finally, you can also explicitly configure which dependencies to bundle into your server bundled
with Vite's ssr.noExternal
option to emulate the Remix compiler's serverDependenciesToBundle
with the Remix Vite plugin.
In production, Vite tree-shakes server-only code from your client bundle, just like the existing Remix compiler. However, in development, Vite lazily compiles each module on-demand and therefore does not tree shake across module boundaries.
If you run into browser errors in development that reference server-only code, be sure to place that server-only code in a .server
file.
At first, this might seem like a compromise for DX when compared to the existing Remix compiler, but the mental model is simpler: .server
is for server-only code, everything else could be on both the client and the server.
The Remix Vite plugin is only intended for use in your application's development server and production builds. While there are other Vite-based tools such as Vitest and Storybook that make use of the Vite config file, the Remix Vite plugin has not been designed for use with these tools. We currently recommend excluding the plugin when used with other Vite-based tools.
For Vitest:
import { unstable_vitePlugin as remix } from "@remix-run/dev";
import { defineConfig, loadEnv } from "vite";
export default defineConfig({
plugins: [!process.env.VITEST && remix()],
test: {
environment: "happy-dom",
// Additionally, this is to load ".env.test" during vitest
env: loadEnv("test", process.cwd(), ""),
},
});
For Storybook:
import { unstable_vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
const isStorybook = process.argv[1]?.includes("storybook");
export default defineConfig({
plugins: [!isStorybook && remix()],
});
Alternatively, you can use separate Vite config files for each tool. For example, to use a Vite config specifically scoped to Remix:
remix vite:dev --config vite.config.remix.ts
Vite is an amazing project, and we're grateful to the Vite team for their work. Special thanks to Matias Capeletto, Arnaud BarrΓ©, and Bjorn Lu from the Vite team for their guidance.
The Remix community was quick to explore Vite support, and we're grateful for their contributions:
Finally, we were inspired by how other frameworks implemented Vite support:
We're definitely late to the Vite party, but we're excited to be here now!