+1 (781) 556-6029

Leaving runtime CSS-in-JS: styling a React app that renders on the server

Two things happened to runtime CSS-in-JS, and most teams only noticed the second one.

The first is maintenance. styled-components announced in 2025 that the project has moved to maintenance mode — security and compatibility fixes, no new feature work. That is not an emergency. It is a dependency whose roadmap has ended while your product's has not, which means the question is now when you move, not whether.

The second is architectural, and it is the one that forces the schedule. A library whose core mechanism is generating class names and injecting a <style> tag during render needs React state and the browser. A React Server Component has neither. Every styled component you import pulls "use client" up the tree with it, and the boundary you drew for data-fetching reasons gets redrawn by your styling layer. Teams adopting the App Router usually discover this about a week in, when the component that was supposed to be a Server Component turns out to render a styled.div.

This post is the path we use for that migration. It is not an argument that CSS-in-JS was a mistake. It solved real problems — scoping, colocation, props-driven variants — at a time when the alternatives were worse, and an app that works and is not adopting Server Components has no obligation to move at all.

First, measure what it actually costs you

Before planning a migration, get numbers. Three measurements, in this order:

Bundle weight. Run your analyzer and find the styling runtime. styled-components is roughly 12–16 kB gzipped depending on version and what you import; Emotion is similar once @emotion/react and @emotion/styled are both in. That is the floor, not the total — every styled component also ships its template literals and interpolation functions as JavaScript rather than as CSS the browser can cache separately.

Render cost. In the React DevTools Profiler, record an interaction on a style-heavy screen — a long table, a virtualized list, a dashboard with many small components. Look at commit durations with and without the styled wrappers in the path. Runtime CSS-in-JS does work during render: serializing interpolations, hashing, checking whether a rule is already injected. On a tree of a few hundred nodes this is usually small. On a tree of several thousand it is measurable, and it shows up as INP on interactions that re-render wide sections of the page.

Hydration and first paint. If you server-render today, check how styles reach the client. With styled-components' SSR setup, the critical CSS is collected during render and inlined into the document. That works, but it is inlined on every response rather than served as a cacheable stylesheet, and in a streaming App Router setup it is precisely the mechanism that does not translate.

Write these three numbers down. They are what you will compare against after the migration, and they are also what tells you how urgent this is. We have seen apps where the styling runtime was 2% of the bundle and invisible in the Profiler. For those teams the honest recommendation is: do not migrate for performance. Migrate when Server Components or the maintenance status force it, and not before.

Choosing the target

There are three realistic destinations. The right one depends on what your current styles actually use, so audit that before choosing.

CSS Modules are the conservative choice. Scoped class names, plain CSS, zero runtime, supported directly by Next.js and Vite with no configuration. Dynamic values move to CSS custom properties — style={{ '--bar-width': pct }} with width: var(--bar-width) in the stylesheet — which covers most of what props-driven interpolation was doing. The cost is that colocation gets weaker: a .module.css file next to the component, rather than the style in the same file. For teams whose CSS-in-JS usage is mostly static styling with a handful of variants, this is the shortest path and the one we most often recommend.

Tailwind is the choice if your team already thinks in utilities or you have a design system with tight token discipline. Styles are in the JSX, the runtime is zero, and the output is a single cacheable stylesheet. Tailwind v4 moved configuration into CSS itself and sped the build up substantially, which removes the main operational objection teams used to have. The real cost is conceptual: it is a different authoring model, and migrating to it means rewriting style authoring, not mechanically translating it. Do not pick Tailwind mid-migration as a side effect; pick it deliberately or not at all.

Zero-runtime CSS-in-JS — vanilla-extract, Panda, Linaria — keeps the TypeScript authoring experience and extracts static CSS at build time. This is the best fit when you have a large design system with typed tokens and variant APIs that you do not want to rewrite, and when the team values type-checked styles. vanilla-extract's recipes map closely onto the variant patterns people built with styled-components' prop interpolation. The cost is a build-time dependency with its own bundler integration, and a smaller ecosystem than either alternative above.

One honest note on all three: whichever you choose, the thing you are giving up is arbitrary runtime style computation from JavaScript values. If you have components computing styles from values only known at runtime — a chart library, a drag-and-drop canvas with pixel positions — those keep inline style props, which is fine and always was. Inline styles are not CSS-in-JS and nothing here asks you to remove them.

The migration, component by component

The thing that makes this migration tractable is that both systems can run at once. Styled components and CSS Modules coexist in the same tree with no bridge, no adapter, and no flag. So do not branch.

Set the boundary first. Add the new system's tooling, convert one leaf component end to end, and ship it. The goal of the first increment is to prove the build config, the class-name ordering against your existing styles, and the review workflow — not to make progress on volume.

Then order by leverage, not by folder. Convert the components that block something: the ones sitting on a Server Component boundary you want to move, and the ones in the hot render path you measured earlier. A migration that converts src/components/a* through src/components/z* in alphabetical order delivers nothing until it is finished. A migration that converts the eight components standing between your product page and a server-rendered layout delivers that layout in week two.

Keep specificity explicit. The most common runtime failure during this migration is ordering: a styled component's injected rule and a CSS Module class both apply, and which one wins depends on injection order, which depends on import order. Two defences. Keep the conversion of a component and all of its direct style overrides in the same commit, so you never have a component styled from both systems at once. And put a visual regression check on the critical screens — Playwright screenshots on a handful of pages are enough — because this class of bug does not fail a unit test and does not throw.

Do not restyle while migrating. This is the discipline that decides whether the project finishes. The moment a conversion PR also adjusts spacing "since we're in here", the diff stops being reviewable, the designer gets involved, and the conversion of one component becomes a two-week thread. Translate the styles exactly, even the ones you think are wrong. Write the wrong ones down and fix them afterwards, as their own change, with someone who owns the design.

Remove the dependency as the last step. When the analyzer shows no remaining imports, delete the provider, the SSR collector, the Babel or SWC plugin, and the package. Until that commit lands you are paying for both systems, so track remaining import count as the metric. It goes to zero or the migration did not happen.

What to re-measure

After the dependency is gone, take the same three measurements. Expect the bundle to drop by the runtime plus the serialized style code — in the apps we have done this for, the total is usually somewhat more than the library's own weight. Expect render-time improvements to be real but modest, and concentrated in wide trees. Expect the clearest win to be the one that is hard to put in a number: the Server Component boundary you wanted is now where you drew it, and styling no longer decides your rendering architecture.

If the numbers did not move and you did not need the server boundary, say so in the pull request. A migration that was worth doing for maintenance reasons is still worth doing. Claiming a performance win that the measurements do not show is how teams end up distrusting the next proposal.