CSS
A production build names your stylesheets after their content, such as /_assets/styles-A1B2C3D4.css, and points the pages’ links at those names, the way it names TypeScript bundles. Your templates link /styles.css as in development. That covers a stylesheet in public/, one Sass compiles there, and the one Stati builds with Tailwind CSS when stati dev and stati build get its input and output files.
Why the name changes
Pages are usually served with no caching or a short one, but a stylesheet with a fixed name can stay in a browser’s cache for hours: some hosts and CDNs cache every .css file by default. After a deploy that adds markup together with the CSS for it, those browsers show the new markup without its styles until their copy expires. A stylesheet named after its content gets a new URL whenever it changes, so browsers load it with the pages that need it.
How pages link the hashed name
A template links the stylesheet under its own path:
<link rel="stylesheet" href="/styles.css" />
A production build writes the page with the hashed name:
<link rel="stylesheet" href="/_assets/styles-A1B2C3D4.css" />
- Every
<link>that applies, offers or preloads a stylesheet is rewritten:rel="stylesheet",rel="alternate stylesheet"andrel="preload" as="style". Thehrefkeeps its quotes, a query or fragment such as?v=2, and the site’s own origin when it carries one, such ashttps://example.com/styles.css. A relativehrefresolves against the page’s URL and becomes a path from the site root. - Links to other sites, links in page text and code samples, and links in comments, scripts and
<template>elements stay as they are. So does a link to a stylesheet inpublic/with anintegritydigest: the digest was taken from the file under its own name, which is still copied. The Tailwind stylesheet has no file under its own name, so such a link to it stops the build: see What a production build does. A1B2C3D4is the start of the SHA-256 of the CSS. A stylesheet in a folder keeps its folder:/css/site.cssbecomes/_assets/css/site-A1B2C3D4.css.- Relative
url()references and@importstrings in the CSS, such asurl(fonts/inter.woff2), become paths from the site root,url(/fonts/inter.woff2), so they load the same files from_assets/as from the stylesheet’s own path. References with CSS escapes are rebased too; comments and other strings, such as acontentvalue, are left alone. - When the CSS changes, Stati writes the new name into the pages the ISG cache kept: a CSS change doesn’t render any page again. When a stylesheet is gone from
public/, those pages link its own path again, and once a stylesheet they link is added topublic/, they link its copy. Only their stylesheet links change. - Hashed files earlier builds left in
dist/_assets/under other names are deleted.
Development keeps every stylesheet’s own name. The first production build that hashes renders every page again.
Stylesheets in public/
A .css file in public/ that a page links gets a hashed copy in dist/_assets/. Nothing needs to be set up.
- The file is still copied under its own name, so a stylesheet that
@imports it, or a script that loads it, keeps working. A file only another stylesheet imports keeps its fixed name. - A stylesheet a page links can’t
@importorurl()the Tailwind stylesheet, such as@import "/styles.css": a hashed build writes that one under its hashed name only, so the build stops. Import it in the Tailwind input instead, or link both stylesheets from the page. - A stylesheet no page links gets no copy.
- Stati copies the CSS as it is: it doesn’t minify it.
- A path holding characters other than letters, digits,
-,_,.and/keeps its name.
Sass
Compile Sass into public/ before stati build, and the build hashes the result like any stylesheet there. Projects that create-stati scaffolds with Sass are set up this way:
{
"scripts": {
"build:css": "sass src/styles.scss public/styles.css --style=compressed --no-source-map",
"watch:css": "sass --watch src/styles.scss public/styles.css --no-source-map",
"build": "npm run build:css && stati build",
"dev": "concurrently --prefix none \"npm run watch:css\" \"stati dev\""
}
}
public/styles.css is a build output, so list it in .gitignore. In development, sass --watch rewrites it, and the dev server copies it and reloads the page.
Tailwind CSS
Setup
Pass the same two flags to both commands:
{
"scripts": {
"dev": "stati dev --tailwind-input src/styles.css --tailwind-output dist/styles.css",
"build": "stati build --tailwind-input src/styles.css --tailwind-output dist/styles.css"
}
}
--tailwind-inputis Tailwind’s input stylesheet.--tailwind-outputis the stylesheet’s file in the output directory. The dev server’s Tailwind watcher writes it, and pages link it under its path there,/styles.css. It must be a.cssfile inside the output directory, with only letters, digits,-,_,.and/in its path. A file at the same path inpublic/, such aspublic/styles.css, is never copied, in development or production, and the build warns about it.
stati build stops with an error when it gets only one of the two. Tailwind CSS 4 needs tailwindcss and @tailwindcss/cli installed in the project, Tailwind CSS 3 needs tailwindcss. See Tailwind CSS integration for the stylesheet itself, including the @source rule for classes your templates build at render time.
A template can link /styles.css or leave the link out: Stati adds <link rel="stylesheet"> before </head> on every page that doesn’t have one to the path. A preload of the path, and a link in a comment or script, don’t count.
What a production build does
- Before any page renders, it checks that the input file exists and that Tailwind CSS is installed.
- It renders the pages. Every class a template builds with
stati.propValue()goes into.stati/tailwind-classes.html. - With that list complete, it runs Tailwind once, with
--minify, and writesdist/_assets/styles-A1B2C3D4.css. - It writes the pages it rendered, with their links to
/styles.csspointed at that file.
Tailwind runs after the pages, so the build holds the pages it renders in memory until then, with css.hash: false too, and writes each once, with the final file name, after the afterRender hook has run for it. If Tailwind fails, the build stops with Tailwind’s error and the previous build’s pages and stylesheet stay as they were.
Unlike a stylesheet in public/, the Tailwind stylesheet isn’t written under its own name, so a deploy carries no copy that no page links. A dist/styles.css the dev server’s watcher wrote stays there out of date; stati build --clean starts from an empty dist/. A reference to /styles.css that the build doesn’t rewrite therefore loads nothing:
- A
<link>to it with anintegrityattribute stops the build, because the link keeps its path. - A stylesheet in
public/that a page links and that references it stops the build. See Stylesheets inpublic/. - A script, a
<template>or another site that loads/styles.cssgets nothing.
To keep the file under its own name, set css.hash to false.
Linking it yourself
The added link goes at the end of <head>, after the page’s other stylesheets. To place it yourself, for example before a stylesheet that has to override it, link /styles.css where you want it. With css.autoInject set to false, Stati adds no link, and templates can print stati.assets.stylesheetPaths:
<head>
<% for (const href of stati.assets.stylesheetPaths) { %>
<link rel="stylesheet" href="<%= href %>">
<% } %>
<link rel="stylesheet" href="/prism-theme.css">
</head>
stati.assets.stylesheetPaths is ['/styles.css'] in development and ['/_assets/styles-A1B2C3D4.css'] in production, and an empty array without the Tailwind flags. Print each path as it is: a production build writes the final name into the page after rendering, by replacing the path the template printed, so a path the template changes, such as one it encodes or takes apart, keeps a name no file has.
Options
The css block in stati.config.ts changes the defaults:
import { defineConfig } from '@stati/core';
export default defineConfig({
site: { title: 'My Site', baseUrl: 'https://example.com' },
css: {
autoInject: false, // link the Tailwind stylesheet in your templates
},
});
| Option | Type | Default | Description |
|---|---|---|---|
hash |
boolean |
true |
Name production stylesheets after their content and point the pages’ links at them. With false, production links every stylesheet under its own name. |
minify |
boolean |
true |
Minify the Tailwind stylesheet in production. Development output and stylesheets in public/ are never minified. |
autoInject |
boolean |
true |
Add the Tailwind stylesheet’s <link> before </head> on every page that doesn’t link it yet. |
Development always uses each stylesheet’s own name, whatever hash says. Unknown options and values that aren’t true or false stop the build with an error.
Caching on your host
The hashed files never change under their names, so they can be cached for as long as you like, along with the TypeScript bundles in the same folder. On hosts that read a _headers file, such as Netlify and Cloudflare Pages, put one in public/:
/_assets/*
Cache-Control: public, max-age=31536000, immutable
Development
stati dev with the Tailwind flags starts Tailwind’s watcher next to the dev server. Pages link /styles.css, and the browser reloads when Tailwind writes it. An output outside the output directory still works for the watcher, but Stati can’t link it, and the dev server says so when it starts. See Development server.
Moving from a build:css script
A project that runs Tailwind or Sass in its own script after stati build writes the stylesheet to dist/ under a fixed name, which the build can’t hash:
{
"scripts": {
"build": "stati build && npm run build:css",
"build:css": "tailwindcss -i src/styles.css -o dist/styles.css --minify"
}
}
To move a Tailwind project:
- Give
buildthe same flags asdev:stati build --tailwind-input src/styles.css --tailwind-output dist/styles.css. - Delete the
build:cssscript.
To move a Sass project, have build:css write public/styles.css instead of dist/styles.css, run it before stati build, and list public/styles.css in .gitignore. See Sass.
Your templates keep their <link rel="stylesheet" href="/styles.css">. The first build renders every page again.