Tasty logoTasty
Playground

Server-Side Rendering

Tasty supports server-side rendering with zero-cost client hydration. This does not introduce a separate styling engine: tasty() uses the same rendering pipeline on the server and in the browser, while the SSR integrations add server-side CSS collection and client-side cache hydration. Your existing tasty() components work unchanged, and SSR remains opt-in with no per-component modifications. For the broader docs map, see the Docs Hub.

Zero-runtime terminology

Zero-runtime delivery is an outcome, not an alias for tastyStatic(). When tasty() components render only on the server, their CSS is delivered with the HTML and no Tasty styling runtime is shipped to the browser. Astro's tastyIntegration({ islands: false }) is the explicit integration for this setup. Server-only Next.js React Server Components follow the same architecture, although you should verify the generated output for your deployment.

tastyStatic() reaches the same client-side outcome by extracting CSS during the build instead of during React rendering. Use it when extraction must happen before rendering or when the consumer is not React; see Build-Time Extraction.


Requirements

DependencyVersionRequired for
react>= 18All SSR entry points (matches the current peer dependency of @tenphi/tasty)
next>= 13Next.js integration (@tenphi/tasty/ssr/next) — App Router with useServerInsertedHTML
Node.js>= 20Generic / streaming SSR (@tenphi/tasty/ssr) — uses node:async_hooks for AsyncLocalStorage

The Astro integration (@tenphi/tasty/ssr/astro) has no additional dependencies beyond react.


How It Works

tasty() components are hook-free and use computeStyles() internally — a synchronous, framework-agnostic function. On the server, computeStyles() discovers a ServerStyleCollector via a registered getter (module-level for Next.js, globalThis for Astro/generic frameworks using AsyncLocalStorage) and collects CSS into it instead of trying to access the DOM. On the client, CSS is injected synchronously into the DOM during render; the injector's content-based cache makes this idempotent. The collector accumulates all styles and serializes them as <style> tags plus a class-list script in the HTML. On the client, hydrateTastyClasses() pre-populates the injector's rules map with the rendered class names so that computeStyles() skips the rendering pipeline entirely during hydration.

Server                         Client
──────                         ──────
tasty() renders                hydrateTastyClasses() reads window.__TASTY__
  └─ computeStyles()              └─ marks rendered class names as already-in-DOM
       └─ collector.collect()
                                 tasty() renders
After render:                    └─ computeStyles()
  <style data-tasty-ssr>             └─ class name known → skip pipeline
  <script> (pushes to __TASTY__)     └─ no CSS re-injection

Next.js (App Router)

1. Create the registry

Create a client component that wraps your tree with TastyRegistry:

// app/tasty-registry.tsx
'use client';

import { TastyRegistry } from '@tenphi/tasty/ssr/next';

export default function TastyStyleRegistry({
  children,
}: {
  children: React.ReactNode;
}) {
  return <TastyRegistry>{children}</TastyRegistry>;
}

2. Add to root layout

Wrap your application in the registry:

// app/layout.tsx
import TastyStyleRegistry from './tasty-registry';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <body>
        <TastyStyleRegistry>{children}</TastyStyleRegistry>
      </body>
    </html>
  );
}

That's it. All tasty() components inside the tree automatically get SSR support. No per-component changes needed.

Optional shared globals stylesheet

By default, configured global CSS is included in the streamed Tasty style tag for every route. withTastyNext() can move that stable CSS into one content-hashed stylesheet shared by all routes while leaving component and hook styles in the normal streaming path.

Keep the Tasty config in a side-effect-free module:

// app/tasty.config.ts
import type { TastyConfig } from '@tenphi/tasty';

const config: TastyConfig = {
  tokens: { $gap: '8px', '#brand': 'rebeccapurple' },
  globalStyles: { body: { margin: '0', color: '#brand' } },
  fontFaces: {
    Brand: { src: 'url("/fonts/brand.woff2") format("woff2")' },
  },
};

export default config;

Use it from the Next config:

// next.config.ts
import { withTastyNext } from '@tenphi/tasty/ssr/next-config';
import config from './app/tasty.config';

export default withTastyNext({
  config,
})({
  // your Next.js config
});

The runtime must receive the same config. Import and configure it before the registry renders:

// app/tasty-registry.tsx
'use client';

import { configure } from '@tenphi/tasty';
import { TastyRegistry } from '@tenphi/tasty/ssr/next';
import config from './tasty.config';

configure(config);

export default function TastyStyleRegistry({ children }) {
  return <TastyRegistry>{children}</TastyRegistry>;
}

The generated file contains eager configuration artifacts: built-in and custom @property rules, tokens and presets, @font-face, @counter-style, native CSS @function definitions, and globalStyles. Route-dependent component rules and calls to useGlobalStyles, useRawCSS, useKeyframes, useProperty, useFontFace, useCounterStyle, and useFunction remain route-specific. Configured keyframes also remain lazy and are streamed only when a route references them.

The default output directory is public/_tasty. The wrapper adds the generated URL to TastyRegistry, respects basePath, preserves existing env and headers config, and serves the content-hashed file with an immutable one-year cache header on Next server deployments. Static exports keep the content-hashed URL and leave cache headers to the hosting provider. Older hashes are not deleted automatically, so rolling deployments cannot break pages from the previous build; clean the generated directory as part of a clean deployment if needed. Page-relative CSS resources such as url(../fonts/brand.woff2) are rejected because moving them would change their meaning; use root-relative, absolute, or data URLs.

withTastyNext() options:

OptionTypeDefaultDescription
configTastyConfigConfig object; takes precedence over configFile
configFilestringProject-relative config module path; requires the optional jiti peer
rootDirstringcurrent directoryNext app root when the build runs from a monorepo root
outputDirstringpublic/_tastyFilesystem output directory
publicPathstringinferred from outputDirRoot-relative URL; required when output is outside public
enabledbooleantrueDisable generation without changing wrapper composition

How it works

Using Tasty in Server Components

All Tasty style functions are hook-free and do not require 'use client'. They can be used directly in React Server Components:

During SSR, all functions discover the collector via the same global getter registered by TastyRegistry — no React context or client boundary needed. In RSC mode without a collector (e.g., Astro zero-setup), CSS is accumulated in a per-request cache and flushed into an inline <style> tag by the next tasty() component in the tree. Ensure at least one tasty() component is present in every RSC render tree — standalone style functions alone cannot emit their CSS without a tasty() component to trigger the flush.

Options

// Skip cache state transfer (saves payload size at the cost of hydration perf)
<TastyRegistry transferCache={false}>{children}</TastyRegistry>

CSP nonce

If your app uses Content Security Policy with nonces, configure it before rendering:

// app/layout.tsx or a server-side init file
import { configure } from '@tenphi/tasty';

configure({ nonce: 'your-nonce-value' });

The nonce is automatically applied to all <style> and <script> tags injected by TastyRegistry.


Astro

Tasty offers several levels of Astro integration. Choose the one that matches your needs:

SetupConfig neededDeduplicationHooks workClient JS
Zero setupNonePer render treeYes (within each tree)None
tastyIntegration({ islands: false })One lineCross-treeYesNone
tastyIntegration()One lineCross-treeYesAuto-hydration
tastyIntegration({ css: { mode: 'extract' } })One lineCross-tree and cross-pageYesAuto-hydration
tastyIntegration({ islands: false, css: { mode: 'extract' } })One lineCross-tree and cross-pageYesNone

Zero setup (static pages)

tasty() components work in Astro with no configuration. Each component emits its own inline <style> tag during server rendering via the RSC inline path. Just import and use:

// src/components/Card.tsx
import { tasty } from '@tenphi/tasty';

const Card = tasty({
  styles: {
    padding: '4x',
    fill: '#surface',
    radius: '1r',
    border: true,
  },
});

export default Card;
---
// src/pages/index.astro
import Card from '../components/Card.tsx';
---

<html>
  <body>
    <Card>Styled with zero setup</Card>
  </body>
</html>

Trade-offs: Styles are deduplicated within each React render tree, but Astro renders separate component trees independently, so shared CSS (tokens, @property rules) may appear more than once. All style functions (useGlobalStyles, useRawCSS, useKeyframes, useProperty, useFontFace, useCounterStyle) work in zero-setup mode — their CSS is accumulated in the RSC cache and flushed by the next tasty() component in the tree.

Best for quick prototyping, small static sites, or trying Tasty out in Astro.

For production use, add tastyIntegration() to your Astro config. This registers middleware automatically and, by default, injects client-side hydration for islands.

With islands (default)

// astro.config.mjs
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import { tastyIntegration } from '@tenphi/tasty/ssr/astro';

export default defineConfig({
  integrations: [react(), tastyIntegration()],
});

This gives you:

All style functions (useGlobalStyles, useRawCSS, useKeyframes, useProperty, useFontFace, useCounterStyle) work on the server.

---
// src/pages/index.astro
import Card from '../components/Card.tsx';
import Interactive from '../components/Interactive.tsx';
---

<html>
  <body>
    <Card>Static -- styles in <style data-tasty-ssr></Card>
    <Interactive client:load>Island -- cache hydrated automatically</Interactive>
  </body>
</html>

Static only (no client JS)

If your site has no client:* islands, skip the hydration script and cache transfer:

// astro.config.mjs
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import { tastyIntegration } from '@tenphi/tasty/ssr/astro';

export default defineConfig({
  integrations: [react(), tastyIntegration({ islands: false })],
});

This gives the same middleware deduplication and hook support, but ships zero client-side JavaScript. No class-list <script> is emitted.

Build-wide CSS extraction

Static Astro builds can move Tasty CSS into content-hashed, browser-cacheable shared and page assets:

export default defineConfig({
  integrations: [
    react(),
    tastyIntegration({
      islands: false,
      css: {
        mode: 'extract',
      },
    }),
  ],
});

css.mode defaults to 'inline', so existing projects keep their current output. Extraction requires Astro 5 or newer and only applies to prerendered production pages. Development, preview-time SSR, and on-demand routes continue to receive the normal inline <style data-tasty-ssr> output.

Extraction writes every artifact emitted by all styled pages to a shared stylesheet. Each page's strict set difference is written to a separate page stylesheet. The shared link comes first and the page link follows, so shared styles form the base cascade and page-only styles can override them. A fully shared page omits the empty page stylesheet. If generated pages have no common artifacts, each page receives only its page stylesheet.

The shared-base/page-override order is the extraction-mode cascade contract. It does not preserve an inline artifact order where a page-only rule originally appeared before a shared rule. Use shared styles for defaults and page-only styles for overrides.

Extracted CSS preserves resource URLs verbatim. Relative URLs in an inline style resolve from the page, but in an extracted stylesheet they resolve from the asset directory. Use absolute URLs or data URLs when extraction is enabled. Root-relative URLs such as url(/fonts/brand.woff2) are also safe while the stylesheet stays on the page's origin. The build fails with a clear error if an artifact contains a page-relative or fragment-only URL, including URL strings in image-set(), image(), src(), and @import.

Assets are written under Astro's configured build.assets directory (for example, /_astro/tasty.shared.a1b2c3.css and /_astro/tasty.page.d4e5f6.css). Links include the configured Astro base, so nested routes do not need relative-path handling. Content hashes and output are deterministic for identical builds. If build.assetsPrefix is configured, Tasty uses its CSS-specific prefix (or fallback) just like Astro-generated stylesheets. When that prefix points to a different origin, root-relative resources would resolve against the asset origin rather than the page's origin, so the build rejects them as well. Tasty compares the prefix with Astro's site when it is configured; without site, an absolute or protocol-relative prefix is treated conservatively as cross-origin. Use a fully absolute resource URL in that configuration.

Manual middleware (advanced)

If you need to compose Tasty's middleware with other middleware (e.g., via sequence()), use tastyMiddleware() directly:

// src/middleware.ts
import { sequence } from 'astro:middleware';
import { tastyMiddleware } from '@tenphi/tasty/ssr/astro';

export const onRequest = sequence(tastyMiddleware(), myOtherMiddleware);

For island hydration with manual middleware, import the client module in a shared entry point or in each island:

import '@tenphi/tasty/ssr/astro-client';

Options

// Skip cache state transfer (static-only, no islands)
export const onRequest = tastyMiddleware({ transferCache: false });

How it works

Astro's @astrojs/react renderer calls renderToString() for each React component without wrapping the tree in a provider. The middleware creates a ServerStyleCollector and binds it via AsyncLocalStorage. All computeStyles() calls within the request discover this collector automatically.

CSP nonce

Call configure({ nonce: '...' }) before any rendering happens. The middleware reads the nonce and applies it to injected <style> and <script> tags. In extraction mode, the external stylesheet links retain the nonce.


Generic Framework Integration

Any React-based framework can integrate using runWithCollector, which binds a ServerStyleCollector to the current async context via AsyncLocalStorage. All style function calls within the render automatically discover the collector.

import {
  ServerStyleCollector,
  createServerStyleCollector,
  runWithCollector,
  hydrateTastyClasses,
} from '@tenphi/tasty/ssr';
import { renderToString } from 'react-dom/server';
import { hydrateRoot } from 'react-dom/client';

// ── Server ──────────────────────────────────────────────

const collector = createServerStyleCollector();

const html = await runWithCollector(collector, () => renderToString(<App />));

const css = collector.getCSS();
const classNames = collector.getRenderedClassNames();

// Embed in your HTML template:
const fullHtml = `
  <html>
    <head>
      <style data-tasty-ssr>${css}</style>
      <script>(window.__TASTY__=window.__TASTY__||[]).push(${JSON.stringify(classNames)})</script>
    </head>
    <body>
      <div id="root">${html}</div>
    </body>
  </html>
`;

// ── Client ──────────────────────────────────────────────

// Before hydration:
hydrateTastyClasses(); // reads from window.__TASTY__

hydrateRoot(document.getElementById('root'), <App />);

Streaming SSR

For streaming with renderToPipeableStream, use flushCSS() instead of getCSS():

const collector = createServerStyleCollector();

const stream = await runWithCollector(collector, () =>
  renderToPipeableStream(<App />, {
    onShellReady() {
      // Flush styles collected so far
      const css = collector.flushCSS();
      res.write(`<style data-tasty-ssr>${css}</style>`);
      stream.pipe(res);
    },
    onAllReady() {
      // Flush any remaining styles + class list
      const css = collector.flushCSS();
      if (css) res.write(`<style data-tasty-ssr>${css}</style>`);

      const classNames = collector.getRenderedClassNames();
      res.write(
        `<script>(window.__TASTY__=window.__TASTY__||[]).push(${JSON.stringify(classNames)})</script>`,
      );
    },
  }),
);

API Reference

Entry points

Import pathDescription
@tenphi/tasty/ssrCore SSR API: ServerStyleCollector, createServerStyleCollector, runWithCollector, hydrateTastyClasses
@tenphi/tasty/ssr/nextNext.js App Router: TastyRegistry component
@tenphi/tasty/ssr/next-configNext.js config wrapper: shared, content-hashed global stylesheet
@tenphi/tasty/ssr/astroAstro: tastyIntegration, tastyMiddleware
@tenphi/tasty/ssr/astro-clientAstro: client-side cache hydration (auto-injected by integration, or import manually)
@tenphi/tasty/ssr/astro-middleware
@tenphi/tasty/ssr/astro-middleware-static
@tenphi/tasty/ssr/astro-middleware-extract
@tenphi/tasty/ssr/astro-middleware-extract-static
Astro: the middleware entrypoints tastyIntegration() registers via addMiddleware(). Exported so Astro can resolve them by specifier; you should not import them. For manual setups use tastyMiddleware().

ServerStyleCollector

Server-safe style collector. One instance per request.

Constructor: new ServerStyleCollector(namePrefix?), or use the createServerStyleCollector(namePrefix?) factory. The optional namePrefix overrides the value from configure({ namePrefix }); in normal usage you pass nothing and let the global config drive it. See Configuration: Name prefix.

MethodDescription
allocateClassName(cacheKey)Allocate a deterministic, content-hashed class name for a cache key (e.g. t1a2b3 with the default prefix). The same cacheKey always produces the same class name on server and client when both share the same namePrefix. Returns { className, isNewAllocation }.
collectChunk(cacheKey, className, rules)Record CSS rules for a chunk. Deduplicated by cacheKey.
collectKeyframes(name, css)Record a @keyframes rule. Deduplicated by name.
allocateKeyframeName(providedName?)Allocate a keyframe name. Returns providedName if given, otherwise generates one using ${namePrefix}k${counter} (e.g. tk0, tk1, ...).
collectProperty(name, css)Record a @property rule. Deduplicated by name.
collectFontFace(key, css)Record a @font-face rule. Deduplicated by content hash.
collectCounterStyle(name, css)Record a @counter-style rule. Deduplicated by name.
allocateCounterStyleName(providedName?)Allocate a counter-style name. Returns providedName if given, otherwise generates one using ${namePrefix}c${counter} (e.g. tc0, tc1, ...).
collectGlobalStyles(key, css)Record global styles (from useGlobalStyles). Deduplicated by key.
collectRawCSS(key, css)Record raw CSS text (from useRawCSS). Deduplicated by key.
collectInternals()Collect eager configured globals: @property, :root tokens and presets, @font-face, @counter-style, @function, and globalStyles. Called automatically on first chunk collection; idempotent.
getCSS()Get all collected CSS as a single string. For non-streaming SSR.
flushCSS()Get only CSS collected since the last flush. For streaming SSR.
getRenderedClassNames()Get the list of class names rendered so far. Serialized to window.__TASTY__ for client hydration via hydrateTastyClasses().

TastyRegistry

Next.js App Router component. Props:

PropTypeDefaultDescription
childrenReactNoderequiredApplication tree
transferCachebooleantrueEmbed cache state script for zero-cost hydration
sharedStylesheetstring | falsegeneratedOverride the shared URL, or disable generated shared CSS for the registry

withTastyNext(options)

Next.js configuration wrapper exported from @tenphi/tasty/ssr/next-config. It generates a shared stylesheet from eager Tasty configuration artifacts and wires its URL and immutable cache header into the Next config. Route-specific CSS continues through TastyRegistry.

tastyIntegration(options?)

Astro integration factory. Registers middleware and optionally injects client hydration.

OptionTypeDefaultDescription
islandsbooleantrueWhen true, injects client hydration script and enables transferCache. When false, no client JS is shipped.

tastyMiddleware(options?)

Astro middleware factory. Use for manual middleware composition.

OptionTypeDefaultDescription
transferCachebooleantrueEmbed cache state script for island hydration

hydrateTastyClasses(classes?)

Pre-populate the client injector's rules map with class names rendered on the server, marking them as already present in the DOM so computeStyles() skips re-injection during hydration. When called without arguments, reads the class list from window.__TASTY__ (populated by the streaming <script> tags emitted during SSR).

runWithCollector(collector, fn)

Run a function with a ServerStyleCollector bound to the current async context via AsyncLocalStorage. All style function calls within fn (and async continuations) — including computeStyles(), useStyles(), useGlobalStyles(), useRawCSS(), useKeyframes(), useProperty(), useFontFace(), and useCounterStyle() — will find this collector.


Troubleshooting

Styles flash on page load (FOUC)

The TastyRegistry or tastyIntegration is missing. Ensure your layout wraps the app with TastyRegistry (Next.js) or that tastyIntegration() is in your Astro config (or tastyMiddleware() is registered manually).

Hydration mismatch warnings

Class names are deterministic for the same render order. If you see mismatches, ensure hydrateTastyClasses() runs before React hydration. For Next.js, this is automatic. For Astro with tastyIntegration(), this is also automatic. For manual Astro middleware setups, import @tenphi/tasty/ssr/astro-client in your island components. For custom setups, call hydrateTastyClasses() before hydrateRoot().

Class names are also derived from the resolved styles, so the server and the client must configure Tasty identically. Anything that changes what a component's styles resolve to will produce a mismatch if it is registered on only one side — namePrefix, recipes, handlers, and the propHandlers / baseStyleProps extension points described in Plugins. Call the same configure() on both; global CSS is deduplicated automatically, so no typeof window guard is needed.

Styles duplicated after hydration

Global CSS (:root tokens, @property, globalStyles, @font-face, @counter-style, @function) configured via configure() is automatically deduplicated. When Tasty detects an inline or extracted [data-tasty-ssr] stylesheet in the document, it skips client-side injection of globals that were already rendered by the SSR collector. This means configure() can be called with the full config on both server and client — no typeof window === 'undefined' guard is needed.

Component CSS: SSR <style data-tasty-ssr> tags remain in the DOM. The client injector creates separate <style> elements for any new styles. SSR styles are never modified or removed by the client. If this is a concern for very large apps, you can remove the SSR style tags and hydration scripts manually after hydration:

import { hydrateTastyClasses } from '@tenphi/tasty/ssr';

hydrateTastyClasses();
hydrateRoot(root, <App />);

// Optional: remove SSR style tags and class-list scripts after hydration
document.querySelectorAll('style[data-tasty-ssr]').forEach((el) => el.remove());
document.querySelectorAll('script').forEach((el) => {
  if (el.textContent?.includes('__TASTY__')) el.remove();
});

AsyncLocalStorage not available

The @tenphi/tasty/ssr entry point imports from node:async_hooks. This is excluded from client bundles by the build configuration. If you see import errors on the client, ensure your bundler treats node:async_hooks as external or use the @tenphi/tasty/ssr/next entry point (which does not use ALS).