Version 0.1.0 just released! Check out the release notes from GitHub Releases

Resolve a Tenant Brand Color at App Router Request Time and Emit Scoped CSS in the Same Server Component

Per-Tenant Theming in App Router Server Components

Multi-tenant SaaS apps regularly need to "white-label" — render the same product with a different brand color per tenant, looked up at request time from a database, env, or CMS. Plain CSS-in-JS solves this by shipping a styling runtime that re-resolves variables on the client. Salty CSS solves it with defineRuntime: the same parser the build-time compiler uses runs on the server, returns a { className, css } pair, and you render the CSS inline next to the element. Nothing styling-related is sent to the browser as JavaScript.

Frameworks: this recipe targets server-render boundaries — Next.js App Router server components and Astro SSR routes. The pure-React/Vite path has no equivalent request-time rendering context; for that, ship a regular Salty CSS conditional variable keyed off a data-tenant attribute instead.

Define the runtime

defineRuntime is a regular .ts import — not a *.css.ts file. Pass it your project config so {colors.brand.primary} and friends resolve to the same CSS variables your build-time styles already use.

// /lib/runtime.ts
import { defineRuntime } from "@salty-css/react/runtime";
import config from "../../salty.config";

export const runtime = defineRuntime(config);

Render a tenant-tinted card

// /components/tenant-card.tsx (server component / Astro frontmatter)
import { runtime } from "../lib/runtime";

interface Tenant {
  id: string;
  brandPrimary: string; // e.g. "#7c3aed", resolved from DB per request
}

export async function TenantCard({
  tenant,
  children,
}: {
  tenant: Tenant;
  children: React.ReactNode;
}) {
  const { className, css } = await runtime.resolve({
    background: tenant.brandPrimary,
    color: "{colors.text.onBrand}",
    padding: "1.5rem",
    borderRadius: "0.5rem",
    "&:hover": { filter: "brightness(1.08)" },
    "@media (min-width: 600px)": { padding: "2rem" },
  });

  return (
    <article className={className}>
      <style>{css}</style>
      {children}
    </article>
  );
}

What ships to the browser is a <style> tag and an <article> with a matching class — both rendered by the server. There is no client component, no styling library on the wire.

Why this beats the obvious alternatives

  • Putting tenant colors in defineVariables doesn't work — the variables are baked at build time and you don't know the tenant yet.
  • A client-side style= works for a single property but can't express :hover, @media, or token references — defineRuntime runs the full parser, so all of those compose.
  • A separate dynamic stylesheet per tenant means an extra request, an extra cache key, and a flash before the rule arrives. Inlining the <style> tag next to the element means the browser sees the rule and the markup in the same chunk.

Deduping across a page

runtime.resolve(styles) hashes the style object — structurally-equal inputs produce the same className. If the same tenant card appears six times on one page, you only need one <style> tag:

// Collect unique pairs at the route level, render them once.
const pairs = new Map<string, string>();
for (const card of cards) {
  const { className, css } = await runtime.resolve(card.styles);
  pairs.set(className, css);
}
// In the JSX:
{[...pairs.values()].map((css, i) => <style key={i}>{css}</style>)}

What features come along

defineRuntime runs the same parser as styled, so token substitution, @media, &:hover, and modifiers all work. Things that need a component wrapper — defaultVariants, passProps, element, as, keyframes — do not. Pick the active variant yourself before calling resolve(...) and pass a flat object.

See the defineRuntime reference for the full support matrix and pitfalls (treating CMS-supplied CSS as untrusted, the variants "all branches emit" caveat, scoping options).

Gotchas

  • Static export doesn't get per-request rendering. A Next.js project with output: "export" runs at build time only — the tenant color you resolve there is whichever tenant the build saw. Switch to a regular server render (no export) for true request-time theming.
  • Treat CMS-supplied CSS as untrusted text. The parser does not sanitize property values. Validate at the CMS boundary if editors are not trusted.
  • Don't put defineRuntime in a *.css.ts file. The compiler ignores its output — defineRuntime is a request-time helper, not a build-time factory.