Guides

CSS Styling

TanStack Start supports the CSS patterns your bundler supports, and adds SSR-aware route asset discovery on top.

Use this guide to choose how to import CSS in a React Start app, and how to configure production CSS behavior such as SSR stylesheet links, Early Hints, and CSS inlining.

Choose a CSS Pattern

Start handles CSS differently depending on how you import it.

PatternUse it whenSSR behaviorProduction features
import css from './app.css?url'You want to put a stylesheet URL in route head()Rendered from head().linksDynamic Early Hints
import './global.css'You want global CSS attached to a route chunkDiscovered from the Start manifest for matched routesStatic Early Hints, transformAssets, CSS inlining
import styles from './card.module.css'You want scoped class names attached to a route chunkDiscovered from the Start manifest for matched routesStatic Early Hints, transformAssets, CSS inlining

Use ?url when the stylesheet is part of your route head output. Use side-effect CSS imports or CSS modules when you want Start to treat the generated stylesheet as a route asset.

Import CSS with ?url when you want the bundler to return the emitted stylesheet URL and you want to render the <link rel="stylesheet"> yourself.

tsx
// src/routes/__root.tsx
/// <reference types="vite/client" />
import { createRootRoute } from '@tanstack/react-router'
import appCss from '../styles/app.css?url'

export const Route = createRootRoute({
  head: () => ({
    links: [{ rel: 'stylesheet', href: appCss }],
  }),
})
// src/routes/__root.tsx
/// <reference types="vite/client" />
import { createRootRoute } from '@tanstack/react-router'
import appCss from '../styles/app.css?url'

export const Route = createRootRoute({
  head: () => ({
    links: [{ rel: 'stylesheet', href: appCss }],
  }),
})

This pattern is useful for explicit global stylesheets, especially when you already want the stylesheet to be part of route head() output. The CSS file is emitted by the bundler, and HeadContent places the stylesheet link in the final document.

?url stylesheet links are route head output, not Start manifest-managed stylesheet assets. Because of that:

  • They are discovered when route head() runs.
  • They can appear in the dynamic Early Hints phase as route head().links entries.
  • They are not rewritten by Start's runtime transformAssets option.
  • They are not inlined by Start CSS inlining.

Use this pattern when explicit control of the route head is more important than Start's manifest-managed CSS features.

Use Side-Effect Imports for Global Route CSS

Import CSS without assigning it when you want global selectors, global class names, or CSS custom properties to be bundled with a route or component module.

tsx
// src/routes/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import '../styles/global.css'

export const Route = createFileRoute('/')({
  component: Home,
})

function Home() {
  return <div className="global-container">Global CSS</div>
}
// src/routes/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import '../styles/global.css'

export const Route = createFileRoute('/')({
  component: Home,
})

function Home() {
  return <div className="global-container">Global CSS</div>
}

The class names remain global. Start discovers the generated CSS asset from the client build and attaches it to the matching route manifest entry. During SSR, HeadContent renders stylesheet links for the matched route tree, so the page is styled before hydration.

Where you import the CSS controls when it is loaded:

  • Import in the root route or app shell to apply it to every page.
  • Import in a layout route to apply it to that layout and its child routes.
  • Import in a leaf route to load it only when that route is matched.
  • Import from a component loaded with import() or React.lazy only when the CSS is not needed for the initial route render.

CSS imported from an async component loads with that async chunk. It is not part of Start's static route manifest for the initial route match, so HeadContent does not render it as an initial SSR stylesheet link, and it is not available for static Early Hints or CSS inlining.

Side-effect CSS imports are a good fit for app-wide CSS resets, design tokens, global utility classes, and route-level global styles.

Use CSS Modules for Scoped Route CSS

CSS modules work like side-effect CSS imports from Start's route asset discovery perspective, but the class names are scoped by the bundler.

tsx
// src/routes/modules.tsx
import { createFileRoute } from '@tanstack/react-router'
import styles from '../styles/card.module.css'

export const Route = createFileRoute('/modules')({
  component: Modules,
})

function Modules() {
  return <div className={styles.card}>Scoped CSS module</div>
}
// src/routes/modules.tsx
import { createFileRoute } from '@tanstack/react-router'
import styles from '../styles/card.module.css'

export const Route = createFileRoute('/modules')({
  component: Modules,
})

function Modules() {
  return <div className={styles.card}>Scoped CSS module</div>
}

The generated stylesheet is discovered from the route chunk graph, linked during SSR for matched routes, and loaded during client navigation when the route chunk loads.

Use CSS modules for route-local or component-local styling when you want scoped class names and Start-managed stylesheet assets.

Know When CSS Is Discovered

The import pattern you choose controls when Start can see the stylesheet.

CSS from side-effect imports and CSS modules is discovered at build time. Start inspects the client build output, records CSS emitted for route chunks, and uses that manifest during SSR. Because Start already knows about these stylesheets before route loaders run, they can be sent in the static Early Hints phase as rel=preload; as=style links.

CSS imported with ?url is different. Start sees that stylesheet only when route head() returns the link, which happens after router.load(). These links can still be sent as Early Hints, but only in the dynamic phase with other supported route head().links entries.

Use this rule of thumb:

  • Use side-effect imports or CSS modules when you want Start to discover route CSS as early as possible.
  • Use ?url when you want explicit route head() control and are comfortable with later discovery.
  • Use allLinks in the dynamic phase when you want one combined Early Hints response with both static manifest assets and dynamic head links.
  • If CSS inlining is enabled, inlined manifest-managed stylesheet assets are skipped by static Early Hints because they are embedded in the HTML.

See the Early Hints guide for the callback and response header APIs.

Inline Route CSS in Production

Experimental: CSS inlining is experimental and subject to change.

CSS inlining embeds Start manifest-managed route CSS directly into the server-rendered HTML response for production builds. This can improve the first render by avoiding blocking stylesheet requests for the CSS needed by the initial route match.

Enable it with server.build.inlineCss in the Start plugin options. Passing true is shorthand for { enabled: true, transformAssets: false }.

ts
// vite.config.ts
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/solid-start/plugin/vite'

export default defineConfig({
  plugins: [
    tanstackStart({
      server: {
        build: {
          inlineCss: true,
        },
      },
    }),
  ],
})
// vite.config.ts
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/solid-start/plugin/vite'

export default defineConfig({
  plugins: [
    tanstackStart({
      server: {
        build: {
          inlineCss: true,
        },
      },
    }),
  ],
})

This option only affects production builds. Development mode keeps using Start's normal development CSS handling.

CSS inlining applies to CSS discovered from side-effect imports and CSS modules, because those stylesheets are Start manifest assets. It does not inline CSS imported with ?url and returned from head().links, because those links are dynamic route head output rather than manifest-managed stylesheet assets.

When CSS inlining is enabled, Start still emits CSS files in the client build. It only changes the SSR document: stylesheet links managed by the Start manifest are replaced with a single inline <style> tag for the matched routes. The inline style is preserved during hydration to avoid duplicate stylesheet links and hydration mismatches.

Control Inlining at Runtime

You can control inlining per request in your server entry. This only affects builds created with server.build.inlineCss enabled.

tsx
// src/server.ts
import {
  createStartHandler,
  defaultStreamHandler,
} from '@tanstack/solid-start/server'
import { createServerEntry } from '@tanstack/solid-start/server-entry'

const handler = createStartHandler({
  handler: defaultStreamHandler,
  inlineCss: ({ request }) => request.headers.get('x-inline-css') !== 'false',
})

export default createServerEntry({ fetch: handler })
// src/server.ts
import {
  createStartHandler,
  defaultStreamHandler,
} from '@tanstack/solid-start/server'
import { createServerEntry } from '@tanstack/solid-start/server-entry'

const handler = createStartHandler({
  handler: defaultStreamHandler,
  inlineCss: ({ request }) => request.headers.get('x-inline-css') !== 'false',
})

export default createServerEntry({ fetch: handler })

For custom runtime wrappers, handler(request, { inlineCss }) overrides the handler-level inlineCss setting for that request.

tsx
export default createServerEntry({
  fetch(request) {
    return handler(request, {
      inlineCss: request.headers.get('x-inline-css') !== 'false',
    })
  },
})
export default createServerEntry({
  fetch(request) {
    return handler(request, {
      inlineCss: request.headers.get('x-inline-css') !== 'false',
    })
  },
})

URL Rebasing

If an inlined stylesheet contains relative url(...) or @import references, Start rebases them relative to the emitted CSS asset URL before embedding the CSS.

For example, when the generated stylesheet is served from /_build/assets/dashboard.css:

css
.card {
  background-image: url('./dot.svg');
}
.card {
  background-image: url('./dot.svg');
}

Start embeds it as:

css
.card {
  background-image: url(/_build/assets/dot.svg);
}
.card {
  background-image: url(/_build/assets/dot.svg);
}

Root-relative URLs are left unchanged at build time. Absolute URLs, protocol-relative URLs, data URLs, and hash references are also left unchanged.

If you need to rewrite CSS-internal URLs at runtime, such as prepending a CDN origin to fonts or background images, opt into CSS URL templates with server.build.inlineCss: { enabled: true, transformAssets: true }. See Transform URLs Inside Inlined CSS for the detailed transformAssets behavior.

Tradeoffs

CSS inlining is useful when the matched route CSS is small enough that putting it in the HTML is cheaper than an extra stylesheet request. It can be less effective when your initial route loads a large global stylesheet that would otherwise be cached as a separate file.

Consider these tradeoffs before enabling it:

  • The HTML response becomes larger.
  • First-load CSS can no longer be cached independently from the HTML response.
  • Strict Content Security Policy setups must allow the inline style. Configure ssr.nonce on the router so HeadContent can apply the nonce to rendered <style> tags, including inlined CSS.
  • CSS files are still emitted for client navigation and browser caching after the initial response.

Use CSS inlining when the reduced request overhead is worth the larger HTML response for your deployment and route structure.

Configure Production CSS Behavior

Use these options together based on what you need in production.

NeedUse
Explicit stylesheet links in route head?url imports returned from head().links
SSR links for route CSSSide-effect imports or CSS modules
Earliest CSS Early HintsSide-effect imports or CSS modules with static hints
Redirect-safe stylesheet Early Hints?url imports with dynamic hints
Fewer blocking CSS requests on first loadserver.build.inlineCss
Runtime CDN rewriting for Start assetstransformAssets