There are two main ways to manage CSS files in Remix:
This guide covers the pros and cons of each approach, and provides some recommendations based on your project's specific needs.
CSS bundling is the most common approach for managing CSS files in the React community. In this model, styles are treated as module side effects and are bundled into one or more CSS files at the discretion of the bundler. It's simpler to use, requires less boilerplate, and gives the bundler more power to optimize the output.
For example, let's say you have a basic Button
component with some styles attached to it:
.Button__root {
background: blue;
color: white;
}
import "./Button.css";
export function Button(props) {
return <button {...props} className="Button__root" />;
}
To use this component, you can simply import it and use it in your route file:
import { Button } from "../components/Button";
export default function HelloRoute() {
return <Button>Hello!</Button>;
}
When consuming this component, you don't have to worry about managing individual CSS files. CSS is treated as private implementation detail of the component. This is a common pattern in many component libraries and design systems and scales quite nicely.
Some approaches to managing CSS files requires the use of bundled CSS.
For example, CSS Modules is built on the assumption that CSS is bundled. Even though you're explicitly importing the CSS file's class names as a JavaScript object, the styles themselves are still treated as a side effect and automatically bundled into the output. You have no access to the underlying URL of the CSS file.
Another common use case where CSS bundling is required is when you're using a third-party component library that imports CSS files as side effects and relies on your bundler to handle them for you, such as React Spectrum.
CSS bundling comes with a notable trade-off when combined with Vite's approach to on-demand compilation.
Using the Button.css
example presented earlier, this CSS file will be transformed into the following JavaScript code during development:
import {createHotContext as __vite__createHotContext} from "/@vite/client";
import.meta.hot = __vite__createHotContext("/app/components/Button.css");
import {updateStyle as __vite__updateStyle, removeStyle as __vite__removeStyle} from "/@vite/client";
const __vite__id = "/path/to/app/components/Button.css";
const __vite__css = ".Button__root{background:blue;color:white;}"
__vite__updateStyle(__vite__id, __vite__css);
import.meta.hot.accept();
import.meta.hot.prune(()=>__vite__removeStyle(__vite__id));
It's worth stressing that this transformation only happens in development. Production builds won't look like this since static CSS files are generated.
Vite does this so that CSS can be compiled lazily when imported and then hot reloaded during development. As soon as this file is imported, the CSS file's contents are injected into the page as a side effect.
The downside of this approach is that these styles are not tied to the route lifecycle. This means that styles won't be unmounted when navigating away from the route, leading to a build-up of old styles in the document while navigating around the app. This can result in CSS rule order differing between development and production.
To mitigate this, it's helpful to write your CSS in a way that makes it resilient against changes to file ordering. For example, you can use CSS Modules to ensure that CSS files are scoped to the files that import them. You should also try to limit the number of CSS files that target a single element since the order of those files is not guaranteed.
Another notable tradeoff with Vite's approach to CSS bundling during development is that React can inadvertently remove styles from the document.
When React is used to render the entire document (as Remix does) you can run into issues when elements are dynamically injected into the head
element. If the document is re-mounted, the existing head
element is removed and replaced with an entirely new one, removing any style
elements that Vite injects during development.
In Remix, this issue can happen due to hydration errors since it causes React to re-render the entire page from scratch. Hydration errors can be caused by your app code, but they can also be caused by browser extensions that manipulate the document.
This is a known React issue that is fixed in their canary release channel. If you understand the risks involved, you can pin your app to a specific React version and then use package overrides to ensure this is the only version of React used throughout your project. For example:
{
"dependencies": {
"react": "18.3.0-canary-...",
"react-dom": "18.3.0-canary-..."
},
"overrides": {
"react": "18.3.0-canary-...",
"react-dom": "18.3.0-canary-..."
}
}
Again, it's worth stressing that this issue with styles that were injected by Vite only happens in development. Production builds won't have this issue since static CSS files are generated.
The other main way to manage CSS files is to use Vite's explicit URL imports.
Vite lets you append ?url
to your CSS file imports to get the URL of the file (e.g. import href from "./styles.css?url"
). This URL can then be passed to Remix via the links export from route modules. This ties CSS files into Remix's routing lifecycle, ensuring styles are injected and removed from the document while navigating around the app.
For example, using the same Button
component example from earlier, you can export a links
array alongside the component so that consumers have access to its styles.
import buttonCssUrl from "./Button.css?url";
export const links = [
{ rel: "stylesheet", href: buttonCssUrl },
];
export function Button(props) {
return <button {...props} className="Button__root" />;
}
When importing this component, consumers now also need to import this links
array and attach it to their route's links
export:
import {
Button,
links as buttonLinks,
} from "../components/Button";
export const links = () => [...buttonLinks];
export default function HelloRoute() {
return <Button>Hello!</Button>;
}
This approach is much more predictable in terms of rule ordering since it gives you granular control over each file and provides consistent behavior between development and production. As opposed to bundled CSS during development, styles are removed from the document when they are no longer needed. If the page's head
element is ever re-mounted, any link
tags defined by your routes will also be re-mounted since they are part of the React lifecycle.
The downside of this approach is that it can result in a lot of boilerplate.
If you have many re-usable components each with their own CSS file, you'll need to manually surface all links
for each component up to your route components, which may require passing CSS URLs up through multiple levels of components. This can also be error prone since it's easy to forget to import a component's links
array.
Despite its advantages, you may find this to be too cumbersome compared to CSS bundling, or you may find the extra boilerplate to be worth it. There's no right or wrong on this one.
It's ultimately personal preference when it comes to managing CSS files in your Remix application, but here's a good rule of thumb:
head
element when re-mounting the page.