Most Gatsby sites built between 2018 and 2022 ship a runtime CSS-in-JS library. gatsby-plugin-styled-components and gatsby-plugin-emotion were in nearly every starter, and at the time the tradeoff was fine: a few kilobytes of runtime in exchange for colocated styles and real theming.
The tradeoff has moved. React 18 and 19 made runtime style injection awkward (styled-components v5 and streaming SSR do not get along, and v6 took a long time to settle), the runtime shows up directly in hydration cost on INP-sensitive pages, and the whole ecosystem has drifted toward CSS that is written at build time. Meanwhile Tailwind 4's Oxide engine and native CSS features — nesting, @layer, custom properties, color-mix() — cover most of what people reached for CSS-in-JS to get.
This tutorial is the migration we actually run on client sites. It assumes Gatsby 5, a site with a few hundred components, and a business that cannot afford a visual regression on the pricing page.
Step 0: Decide whether this is worth doing
It often is not. Measure before you commit.
npx gatsby build
npx source-map-explorer 'public/*.js' --html report.html
Look for styled-components, @emotion/react, @emotion/styled, and stylis in the treemap. On a typical content site you will find 12–18 kB gzipped of runtime plus whatever your theme object and helper functions add. Then check what that costs in practice:
- Open a Performance trace in Chrome DevTools with 4x CPU throttling, hydrate the heaviest page, and look at scripting time attributable to style injection.
- Check field data (CrUX or your RUM tool) for INP on the pages that matter. If INP is already under 200 ms at p75 and your bundle is small, a styling migration is not your highest-value work. Go fix images or third-party scripts instead.
The cases where removal clearly pays off:
- You are already upgrading React. Doing the styling migration in the same window avoids paying the regression-testing cost twice.
- The library is a blocker. styled-components v5 on React 18+ produces hydration warnings and, in streaming setups, unstyled flashes.
- You are replatforming off Gatsby. Astro, Next.js App Router, and React Router v7 all prefer build-time CSS. Converting styles while still on Gatsby — where your build is understood and your test harness works — is far cheaper than doing it mid-port. This is the sequencing we recommend most often.
- Build times. Emotion's Babel plugin and large theme objects measurably slow SSR rendering of thousands of pages.
Step 1: Pick the target
Three realistic destinations. Pick one; do not mix two new systems into a codebase that already has one.
CSS Modules. Built into Gatsby with no plugin: name a file Button.module.css and import it. Zero runtime, zero new dependencies, trivially understood by any developer who joins later. Best for sites with a modest component count and designers who write CSS. Dynamic styles move to CSS custom properties:
import * as styles from './Card.module.css';
export function Card({ accent, children }) {
return (
<div className={styles.card} style={{ '--card-accent': accent }}>
{children}
</div>
);
}
/* Card.module.css */
.card {
border-left: 4px solid var(--card-accent, var(--color-border));
padding: var(--space-4);
}
Tailwind 4. Best for teams building lots of new pages. Tailwind 4 configures in CSS rather than a JS config file, which removes a whole class of Gatsby-specific wiring:
npm install tailwindcss @tailwindcss/postcss postcss
// postcss.config.js
module.exports = { plugins: { '@tailwindcss/postcss': {} } };
/* src/styles/global.css */
@import "tailwindcss";
@theme {
--color-brand: #1b4fd8;
--font-display: "Inter", system-ui, sans-serif;
--space-section: 4rem;
}
// gatsby-browser.js
import './src/styles/global.css';
Tailwind 4 needs a browser baseline of roughly Safari 16.4+/Chrome 111+ because it leans on @property and color-mix(). If your analytics show meaningful traffic below that, stay on Tailwind 3.4 or choose CSS Modules.
Zero-runtime CSS-in-JS (vanilla-extract, Panda CSS, Linaria). Keeps the authoring ergonomics and type safety your team already likes while emitting static CSS. The catch on Gatsby: these rely on bundler plugins, and Gatsby's webpack config is yours to extend via onCreateWebpackConfig. vanilla-extract works well; verify it in both develop and build stages before you port 200 components.
// gatsby-node.js
const { VanillaExtractPlugin } = require('@vanilla-extract/webpack-plugin');
exports.onCreateWebpackConfig = ({ actions, stage }) => {
actions.setWebpackConfig({
plugins: [new VanillaExtractPlugin()],
});
};
Step 2: Freeze the design tokens first
The single biggest mistake is porting components before the tokens exist. Your theme.js is the source of truth today; turn it into CSS custom properties generated from the same file so both systems read one set of values during the migration.
// scripts/tokens.js — run in prebuild
const fs = require('fs');
const theme = require('../src/theme');
const lines = [':root {'];
for (const [name, value] of Object.entries(theme.colors)) {
lines.push(` --color-${name}: ${value};`);
}
for (const [i, value] of theme.space.entries()) {
lines.push(` --space-${i}: ${value};`);
}
lines.push('}');
fs.writeFileSync('src/styles/tokens.css', lines.join('\n') + '\n');
{
"scripts": {
"prebuild": "node scripts/tokens.js",
"predevelop": "node scripts/tokens.js"
}
}
Now a half-migrated component tree cannot drift: the old ${props => props.theme.colors.brand} and the new var(--color-brand) resolve to the same hex string.
Step 3: Migrate leaves first, behind a visual gate
Order the work by dependency depth, not by importance. Buttons, badges, and typography primitives first; layout shells and templates last. Each PR should be small enough that a reviewer can read the CSS diff.
Before you start, capture a baseline. If you already have Playwright in CI, add screenshot coverage for the routes you care about:
// tests/visual.spec.js
const routes = ['/', '/services/', '/pricing/', '/blog/', '/contact/'];
for (const route of routes) {
test(`visual ${route}`, async ({ page }) => {
await page.goto(`http://localhost:9000${route}`);
await page.addStyleTag({ content: '*, *::before, *::after { animation: none !important; transition: none !important; }' });
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot(`${route.replace(/\//g, '_')}.png`, {
fullPage: true,
maxDiffPixelRatio: 0.002,
});
});
}
Run it against gatsby serve on the pre-migration build, commit the snapshots, then let every migration PR diff against them. Deliberate design changes mean updating a snapshot in the same PR — which is exactly the review signal you want.
A useful grep-based progress metric:
grep -rl "styled-components\|@emotion" src/ | wc -l
Put that number in the PR description. It should only go down.
Step 4: Handle the four things that always break
Global styles and resets. createGlobalStyle moves to a plain stylesheet imported in gatsby-browser.js. Check specificity: styled-components injected globals late in <head>, so a naive port can suddenly lose to component styles.
Theme switching. If you had a ThemeProvider with light/dark objects, replace it with a data-theme attribute on <html> and two custom-property blocks. Set it in gatsby-ssr.js with an inline script so there is no flash:
// gatsby-ssr.js
exports.onRenderBody = ({ setPreBodyComponents }) => {
setPreBodyComponents([
<script
key="theme"
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.dataset.theme=t;}catch(e){}})();`,
}}
/>,
]);
};
The as prop and polymorphic components. <Button as="a" href="..."> has no direct equivalent. Write it out explicitly, or keep a tiny wrapper that picks the element and applies the same class list. Explicit is usually better; this is where most CSS-in-JS codebases hid their accessibility bugs (buttons that were really divs).
Styles that depend on runtime props. Anything continuous — a progress bar width, a dynamic brand colour from the CMS — stays an inline custom property. Anything discrete — variants, sizes, states — becomes a class. If you find yourself generating class names from arbitrary data, you have reinvented the runtime.
Step 5: Remove the plugin and verify
Only once the grep count is zero:
npm uninstall gatsby-plugin-styled-components styled-components babel-plugin-styled-components
Remove the plugin entry from gatsby-config.js, delete the Babel plugin entry, then rebuild and check the output rather than trusting the dev server:
rm -rf .cache public && npx gatsby build
grep -rc "stylis" public/*.js | grep -v ':0' || echo "runtime gone"
npx source-map-explorer 'public/*.js' --html after.html
Then confirm the CSS itself actually loads as a static file. A correctly migrated Gatsby site serves a styles.<hash>.css from <link rel="stylesheet"> in the static HTML — not a <style> tag written by JavaScript during hydration:
grep -o '<link[^>]*stylesheet[^>]*>' public/index.html
Finally, re-run the Playwright visual suite against production-like output, and re-measure INP on the pages you traced in Step 0. On the last three of these we ran, the typical result was 14–20 kB gzipped removed from every page, a measurable drop in hydration scripting time on mid-range mobile, and — the part clients notice more — no more unstyled flash on slow connections.
What we would skip
Do not do this migration and a framework migration in the same pull request. Do not convert styles "while you are in there" on unrelated feature work; the diff becomes unreviewable. And do not start without the visual baseline — without snapshots, a styling migration is a six-week project that ends with someone finding a broken layout in production three months later.
If you are weighing a styling migration against a replatform, or you want the visual-diff harness in place before anyone touches a component, get in touch — it is the kind of work that goes much faster with a second pair of eyes on the CSS.