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-tenantattribute 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
defineVariablesdoesn'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 —defineRuntimeruns 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 (noexport) 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
defineRuntimein a*.css.tsfile. The compiler ignores its output —defineRuntimeis a request-time helper, not a build-time factory.