Driving Hugo styles from config is a well-worn pattern. You run a SCSS entry file through resources.ExecuteAsTemplate, drop {{ site.Params... }} where the values go, then hand the rendered resource to css.Sass. Hugo writes the config values first; Sass compiles the result.

Regis Philibert wrote up the Hugo Pipes version of that pattern in 2018, when Hugo Pipes landed. It works, and I have used it for years.

The pattern has a boundary that is easy to miss: template execution is not recursive through the Sass module graph.

ExecuteAsTemplate renders the one resource you give it. Sass resolves files loaded with @use or @import later. By then, Hugo has finished its template pass. So if a Sass-loaded file still contains {{ }}, the Sass compiler sees those tags as literal text.

You see it as this:

TOCSS: failed to transform "base.scss" (text/x-scss):
Invalid CSS after "...y_CMS_color }};": expected "}", was "{{ if site.Params.f"

That is not a bad Sass path. It is not fixed by renaming a partial. It is a phase-order problem.

The fix is to move generated SCSS composition back into the template phase.

Why Sass loading cannot carry Hugo template logic

There are two stages:

  1. Hugo executes the template.
  2. Sass compiles the rendered SCSS.

resources.ExecuteAsTemplate belongs to the first stage. It parses and executes the entry resource as a Go template.

Sass @use and legacy Sass @import belong to the second stage. They are resolved by the Sass compiler after Hugo has already produced the intermediate SCSS.

That means this will not do what you want:

/* assets/scss/base.tmpl.scss */

@use "tokens/colors";
@use "tokens/typography";

If tokens/_colors.scss contains Hugo template logic, Hugo never sees it. Sass loads that file later, and the template tags arrive at the compiler untouched.

The wall is not that SCSS partials are impossible to template. The wall is that Sass-loaded files are not template-rendered unless you explicitly run them through Hugo first.

Route around it with Hugo partials

Hugo partials execute during template rendering, which is the stage we want.

So instead of asking Sass to load a file that still contains Hugo template logic, include a Hugo partial that prints SCSS.

{{/* assets/scss/base.tmpl.scss */}}

{{ $framework := site.Params.css.frameworks.name | default "" }}

{{ partial "scss/colors.html" . }}
{{ partial "scss/typography.html" . }}
{{ partial "scss/spacing.html" . }}

{{ if eq $framework "bootstrap" }}
  {{ partial "scss/adapters/bootstrap.html" . }}
{{ else if eq $framework "tailwind" }}
  {{ partial "scss/adapters/tailwind.html" . }}
{{ end }}

Each partial runs while Hugo is executing the entry template. Each one emits SCSS. By the time css.Sass runs, there is no template syntax left. There is only generated SCSS.

I keep these files under Hugo’s partials directory:

layouts/_partials/scss/colors.html
layouts/_partials/scss/typography.html
layouts/_partials/scss/spacing.html
layouts/_partials/scss/adapters/bootstrap.html
layouts/_partials/scss/adapters/tailwind.html

The .html extension looks wrong the first time. The output is SCSS, but the file is a Hugo partial template. .html is the boring, predictable choice unless you have deliberately configured another template suffix.

Here is the pipeline that renders the SCSS template and then compiles it:

{{ with resources.Get "scss/base.tmpl.scss" }}
  {{ $scss := . | resources.ExecuteAsTemplate "scss/base.generated.scss" $ }}
  {{ $opts := dict
    "targetPath" "css/base.css"
    "transpiler" "dartsass"
  }}

  {{ with $scss | css.Sass $opts | minify | fingerprint }}
    <link
      rel="stylesheet"
      href="{{ .RelPermalink }}"
      integrity="{{ .Data.Integrity }}"
      crossorigin="anonymous">
  {{ end }}
{{ end }}

One detail matters: ExecuteAsTemplate caches by the target path you pass it. For a site-wide token file, a stable path such as scss/base.generated.scss is fine. If you generate page-specific CSS, make the target path page-specific too.

A token partial

A color partial can read project config and data files, then emit custom properties.

{{/* layouts/_partials/scss/colors.html */}}

{{ with site.Params.design.colors.primary }}
:root {
  --primary-color: oklch({{ .l }} {{ .c }} {{ .h }});

  {{ with site.Params.design.colors.secondary }}
  --secondary-color: oklch({{ .l }} {{ .c }} {{ .h }});
  {{ end }}

  {{ with hugo.Data.branding.primary_color }}
  --primary-cms-color: {{ . }};
  {{ end }}
}
{{ else }}
  {{ errorf "Missing required site param: params.design.colors.primary" }}
{{ end }}

The color space does not matter. Use OKLCH, hex, HSL, or whatever your project needs. The important part is the shape: a real, separated Hugo partial generates one part of the stylesheet.

The hugo.Data example assumes the CMS writes JSON, TOML, YAML, or XML into Hugo’s data/ directory, or into a directory mounted as Hugo data. If the CMS writes page front matter instead, read from .Params or .Param instead.

A framework adapter

With partials as the composition boundary, framework mapping becomes a build-time choice.

{{/* layouts/_partials/scss/adapters/bootstrap.html */}}

:root {
  --bs-primary: var(--primary-color);
  --bs-secondary: var(--secondary-color);
}

.btn-primary {
  background-color: var(--primary-color);
  border-color: var(--primary-color);
}

The entry file decides whether this adapter is included:

{{ if eq $framework "bootstrap" }}
  {{ partial "scss/adapters/bootstrap.html" . }}
{{ end }}

That lets the same boilerplate generate site tokens, then map them onto Bootstrap for one project, a utility stack for another, or no framework at all.

When not to use this

If all you need is to pass a few scalar values from Hugo config into Sass variables, use Hugo’s css.Sass vars option instead.

That path is cleaner for simple variable injection:

{{ $vars := dict
  "primary-color" site.Params.design.colors.primary_hex
  "font-family" site.Params.design.typography.font_family
}}

{{ $opts := dict
  "transpiler" "dartsass"
  "vars" $vars
}}

{{ with resources.Get "scss/main.scss" | css.Sass $opts }}
  <link rel="stylesheet" href="{{ .RelPermalink }}">
{{ end }}

Then in Sass:

@use "hugo:vars" as v;

body {
  font-family: v.$font-family;
}

:root {
  --primary-color: #{v.$primary-color};
}

That is the supported route for simple values.

The partial pattern is for generated structure: loops, conditionals, token blocks, CMS-fed custom properties, framework adapters, selectors generated from data files, or any case where the stylesheet itself needs Hugo template control flow.

What this structure buys you

Tokens stop living in one file. Colors, typography, spacing, and motion can each get a partial. The entry file becomes a manifest: what to include, and in what order.

Framework adapters become a build-time switch. A config value chooses whether the generated tokens map onto Bootstrap, Tailwind, a custom CSS-variable layer, or nothing.

CMS data can flow into the token layer. If a client edits a value that lands in Hugo data, the next build emits that value into the stylesheet without another integration point.

The boundary also becomes clear. Sass modules are still useful for Sass-only code. Use @use and @forward for mixins, functions, and static Sass dependencies. Use Hugo partials for SCSS that must be generated by Hugo.

What is actually new here

The mechanism is not new.

Templating an asset with resources.ExecuteAsTemplate is documented. Hugo partials have always been able to print arbitrary output. Sass has its own module system, and old @import-based examples have been around for years.

The architectural move is the point: use Hugo partials as the composition unit for generated SCSS, and reserve Sass loading for Sass-only code.

Config, data files, and build-time framework adapters stay in Hugo’s template phase. Sass receives plain SCSS.

For a single site, that may be overkill. For a reusable multi-site boilerplate, it is the difference between one fat templated stylesheet and a token system you can reason about.