GioJSdocs
On this page

CSS & Styling

Import stylesheets and CSS Modules from any page, layout or component. GioJS bundles each route's CSS into content-hashed files and links them for you.

Global CSS

Import global stylesheets from the root layout. That is the recommended setup: every page, including not-found and error pages, gets them first.

app/layout.tsx
import React from 'react';
import './globals.css';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head />
      <body>{children}</body>
    </html>
  );
}

Side-effect imports (import './x.css') work in any page, layout, error/loading file or component. Never add a <link> for an imported file: GioJS links it.

How imported CSS ships

At startup (and after every change in dev) GioJS follows each route's imports and bundles the CSS it reaches. That covers the page, its layouts and error/loading files, the components they use, and the root layout, which never ships to the browser itself. Each page links two stylesheets: one shared by every page, holding the root layout's CSS, and one for the route that leaves out whatever the shared one already has. Cascade order follows the tree: root layout, then layouts outer to inner, then the page, and within a file the order of its imports.

  • Files are served from /_next/static/css/ with content-hashed names and Cache-Control: public, max-age=31536000, immutable, and minified in production ([css] minify = false in gio.toml keeps them as written; a standalone build bakes them, so there the build reads the key). Imported CSS is part of the module graph: no [css] key turns its bundling off.
  • The links are React stylesheet resources (precedence="default"), so they land in <head>, on streamed pages too. On client navigation the next route's new stylesheets load before it is shown, so it never flashes unstyled.
  • url() references to files next to the CSS (images, fonts) are copied next to the stylesheet with hashed names. Site-absolute URLs such as url(/public/bg.png) stay as written.
  • CSS that an npm package ships must be imported explicitly, e.g. import 'some-lib/dist/styles.css'. GioJS does not look inside npm packages' JavaScript for CSS imports.
  • @import inside a stylesheet is bundled too, including a bare package name such as @import "modern-normalize";. The package's stylesheet is found through its style export condition or style field, or else its main. Remote URLs (@import url("https://…")) and site-absolute paths stay as written.

Global CSS stays global when you navigate. Once a route's stylesheet has loaded it stays in the page, so a global rule from one route can still apply after navigating to another. Keep page-specific styles in CSS Modules.

CSS Modules

A file named *.module.css has locally scoped class names. Import its default export and use the generated names:

app/blog/card.module.css
.card { padding: 1rem; }
.title { composes: heading from '../shared.module.css'; color: teal; }
:global(.prose) h2 { margin-top: 2rem; }
tsx
import styles from './card.module.css';

export default function Card({ title }: { title: string }) {
  return (
    <article className={styles.card}>
      <h2 className={styles.title}>{title}</h2>
    </article>
  );
}

The server render and the browser bundle use exactly the same class names, so pages hydrate cleanly. A name has the form card_3fa9c1_title: the file name, a hash, and the local name. The hash covers the package the file belongs to (the name in its package.json, plus the version for an installed package) and the file's path inside it. So Button.module.css in your app and in a workspace UI package get different names, and a name stays the same across builds and machines. Modules are compiled with esbuild's CSS Modules support, and these are its rules:

  • Class names, ids and @keyframes names are local. Element selectors and attribute selectors are not affected.
  • :global(.name), or :global before a selector, keeps the name global. :local(...) marks a name local explicitly.
  • composes: a b; adds classes from the same file to a class's exported value, and composes: a from './other.module.css'; adds classes from another module. styles.title above is "shared_…_heading card_…_title".
  • Use the default import (import styles from). Names that are not valid JS identifiers are read with brackets: styles['nav-link'].
  • CSS Modules need an ES module project ("type": "module"in package.json, which every new GioJS project has). In a CommonJS project, global CSS imports still work.

TypeScript

At every server start GioJS writes .gio/css-modules.d.ts next to the generated .gio/routes.d.ts, which references it. It types import styles from './x.module.css' as a map of class names to strings and lets plain .css imports through:

ts
declare module '*.module.css' {
  const classes: { readonly [className: string]: string };
  export default classes;
}
declare module '*.css' {}

Projects scaffolded by create-giojs already include .gio/routes.d.ts in their tsconfig. In an existing project, add ".gio/routes.d.ts" to the include array of tsconfig.json. Both files are written when the server starts, so on a fresh checkout (in CI, for example) start it once before running tsc. With noUncheckedIndexedAccess on, a class reads as string | undefined, which className accepts.

Tailwind CSS

npm create giojs@latest -- --tailwind (or npx create-giojs add tailwind in an existing app) sets up everything below, including a dev script that runs the watcher next to the server - see the Tailwind guide.

GioJS doesn't process Tailwind directives itself. Run Tailwind v4's CLI next to the server and import the CSS it generates. You can use npx @tailwindcss/cli or the dependency-free standalone tailwindcss binary. The input file is plain CSS:

app/tailwind.css
@import "tailwindcss";
bash
# dev: rebuild the output whenever a class is added
npx @tailwindcss/cli -i ./app/tailwind.css -o ./app/tailwind.out.css --watch

# before deploying (or in CI)
npx @tailwindcss/cli -i ./app/tailwind.css -o ./app/tailwind.out.css --minify
app/layout.tsx
import './tailwind.out.css';

Tailwind finds class names by scanning your project's source files. In dev, every CLI rebuild changes tailwind.out.css, and that rebuilds the stylesheets and reloads the browser. Import the output file, never the input: the bundler doesn't compile @import "tailwindcss". Run the minify command before gio export or gio build standalone as well, so the output is up to date.

Stylesheets served by path (legacy)

Linking a stylesheet by URL still works. Files under public/ are served directly by Rust:

tsx
<link rel="stylesheet" href="/public/styles/globals.css" />

Every non-module .css file under app/ is also transformed (and minified in production, unless [css] minify = false) once at startup and served from memory at its path ([css] enabled = false turns this off): app/globals.css answers at /globals.css. Those URLs carry no content hash, so they are served with Cache-Control: public, max-age=0, must-revalidate and a strong ETag. Browsers revalidate on each use and get a bodiless 304 while the file is unchanged.

On cached pages that don't link imported stylesheets, app/globals.css is also the source for critical CSS extraction: the rules a page uses are inlined and the full file loads without blocking render. Pages that import CSS skip that step. Their CSS, often globals.css itself, is already linked, and loading the file a second time would let it override the route's own rules.

  • Pick one way per file: import it, or link it by path. A file linked both ways loads twice, and stylesheets linked by hand in <head> come after the imported ones in the cascade.
  • *.module.css files are never served by path. Their class names only exist in the import pipeline, so a second copy with different names would be useless.

Dev, export and standalone builds

In dev, editing any CSS file rebuilds the stylesheets, restarts the worker and reloads open tabs. gio export writes the stylesheets to out/_next/static/css/, and gio build standalone ships them in the deploy folder's static/, with the CSS Module class names compiled into worker.js. Both link them exactly as the server does.