How does it work
This is for the curious minds who want to know how `next-yak` works under the hood and it's not necessary to understand it in order to use it.
Playground
A live playground to see the code transformation can be found here.
The basics
next-yak has 3 parts:
- The compile time part (SWC plugin + bundler integration)
- The runtime part (minimal class name merging)
- Optional: Context (theme provider)
The compile time part is responsible for extracting styles and transforming your code into something that uses the runtime part. The runtime part is responsible for merging styles and props.
next-yak supports three bundler backends: webpack (Next.js), Turbopack (Next.js), and Vite, each with its own integration layer. The SWC plugin is shared across all of them, while each bundler has a dedicated loader/plugin for CSS delivery.
The compile time part
next-yak uses a Rust-based SWC plugin and a bundler-specific loader to transform your code. The SWC plugin transforms the tagged template literals (like styled and css) and extracts CSS, while the loader resolves cross-module dependencies and delivers the CSS through the bundler's native CSS pipeline.
The SWC plugin
See: yak-swc
The SWC plugin takes in the source code and transforms the tagged template literals into next-yak runtime function
calls with the imported styles (from the final css module output) as arguments. It also transforms the dynamic parts of the
tagged template literals into function calls with the properties as argument and adds it as css value to the style object.
Change import
The first step of the transformation is to change the import statement from styled and css to the next-yak runtime.
import * as __yak from "next-yak/internal";The normal import just references a kind of stub behavior to be used in tests without the need to have a transpilation step.
Add style import
The next step of the transformation is to add the import statement for the styles. These styles don't exist yet, but will be generated by the bundler-specific loader/plugin at a later stage. Since next-yak controls all generated class names (passing them as strings directly), this import does not need to export class name bindings. The import format depends on the bundler:
import "./page.yak.module.css!=!./page?./page.yak.module.css";This strange looking import string is a poorly documented feature of webpack. It uses the Inline matchResource to instrument webpack to use the loaders as if it was a normal .module.css file
that points to the current file (./page). In order that this import statement works in Next.js, we need to add
a query parameter that ends with .module.css, as this is the way Next.js recognizes CSS modules.
This recognition is not always working correctly as seen with the various issues and PRs we created on the Next.js repository like this one, this one or this one.
So that means ./page.yak.module.css is a virtual file that is afterwards generated by the webpack loader and contains the
styles of the current file. ./page.yak.module.css!=!./page says that the loader (matching .yak.module.css files) should
be used with the current file as input. Lastly ?./page.yak.module.css is the query parameter that tells Next.js that this
is a CSS module and its loaders should be used.
import "data:text/css;base64,LkJ1dHRvbiB7Li4ufQ==";Turbopack uses a different approach than webpack. Instead of using inline match resources, the SWC plugin encodes the extracted CSS as base64 data URLs. This allows Turbopack to handle the CSS without needing the complex resource matching syntax that webpack uses.
import "virtual:yak-css:{{__MODULE_PATH__}}.css";For Vite, next-yak uses a virtual module system. The {{__MODULE_PATH__}} placeholder is replaced by the SWC plugin with the actual file path during transformation. The Vite plugin then resolves these virtual modules and serves the extracted CSS through Vite's standard CSS pipeline.
Transform tagged template literals
In another step, the tagged template literals are transformed into next-yak runtime function calls. The static CSS parts are passed as class name strings directly, since next-yak controls all generated class names.
import { styled } from "next-yak";
const Button = styled.button` font-size: 2rem;
font-weight: bold;
color: blue;
&:hover {
color: red;
}`;
import * as __yak from "next-yak/internal";
const Button = __yak.__yak_button(
"input_Button_m7uBBu"
);The dynamic parts are preserved and apply the classes or CSS variables at runtime. There are two types of dynamic parts:
Dynamic CSS property values are transformed into CSS variables. The function is preserved and its return value is set as an inline style on the element at runtime:
import { styled } from "next-yak";
const Button = styled.button` color: ${(props) => props.$primary
? "white"
: "blue"
};`;
import * as __yak from "next-yak/internal";
const Button = __yak.__yak_button(
"input_Button_m7uBBu",
{
"style": { "--input_Button__color_m7uBBu":
(props) => props.$primary
? "white"
: "blue",
},
}
);Dynamic CSS blocks (conditional styles using css) are extracted into separate class names that are toggled at runtime based on the function's return value:
import { styled, css } from "next-yak";
const Button = styled.button`
font-size: 2rem;
${(props) => props.$primary &&
css`
background: black;
`}
`;
import { css } from "next-yak/internal";
import * as __yak from "next-yak/internal";
const Button = __yak.__yak_button(
"input_Button_m7uBBu",
(props) => props.$primary &&
css("input_Button___m7uBBu"),
);Transform CSS
The next step is to extract the CSS to a comment in the source code. This is useful because the transformation should happen as quickly as possible and this is way easier and faster with the SWC plugin instead of the bundler loader and we're already transforming the current file.
import { styled, css } from "next-yak";
const Button = styled.button`
font-size: 2rem;
font-weight: bold;
color: ${props => props.$primary
? "white" : "blue"};
${props => props.$primary
? css`background: black;`
: css`background: white;` }
&:hover {
color: ${props => props.$primary
? "red" : "blue"};
}`;
/*YAK Extracted CSS:
.input_Button_m7uBBu {
font-size: 2rem;
font-weight: bold;
color: var(--input_Button__color_m7uBBu);
}
.input_Button___m7uBBu {
background: black;
}
.input_Button___m7uBBu1 {
background: white;
}
.input_Button_m7uBBu {
&:hover {
color: var(
--input_Button__color_m7uBBu1
);
}
}
*/The comment gets marked with /*YAK Extracted CSS: so that the loader can easily find it and extract the CSS.
Additionally, the comment is marked with /*#__PURE__*/ so that the minifier knows that behind the runtime function call there is no side effect and can move it around.
The bundler loaders/plugins
Each bundler has a dedicated loader or plugin that takes the SWC plugin output, resolves cross-module dependencies, and delivers the final CSS through the bundler's native CSS pipeline.
- Webpack (webpack-loader.ts): The loader reads the extracted CSS from the SWC plugin comments, resolves cross-module dependencies, and hands the final CSS over as a CSS module file so it can be processed by Next.js like a normal css-modules file.
- Turbopack (turbo-loader.ts): The loader works similarly to the webpack loader but the CSS is already encoded inline as a data URL by the SWC plugin, so no virtual file resolution is needed.
- Vite (vite-plugin.ts): The plugin resolves the virtual module imports and serves the extracted CSS through Vite's standard CSS pipeline.
The runtime part
See: styled.tsx
Despite being called "zero-runtime", next-yak does ship a small runtime. What "zero-runtime" means here is that there is no code that manipulates the DOM or CSSOM. No <style> or <link> elements are injected at runtime.
The runtime is essentially a type-safe classnames utility. It takes the generated class name strings and the component's props, evaluates the dynamic functions (conditionals, CSS variable values), and returns:
- The final
classNamestring (merging base classes and conditional classes) - An optional
styleobject (setting CSS variable values from props)
Since it has no side effects, it works in both server and client components.
Here is a simplified version of what __yak.__yak_button does:
function __yak_button(className, ...dynamicParts) {
// returns a React component
return (props) => {
const classNames = new Set([className]);
const style = {};
for (const part of dynamicParts) {
if (typeof part === "function") {
// conditional css block, e.g.:
// (props) => props.$primary && css("Button_primary")
// css("Button_primary") returns a function that
// adds "Button_primary" to the classNames set
const result = part(props);
if (result) result(props, classNames, style);
} else if (part?.style) {
// css variable values, e.g.:
// { style: { "--color": (props) => props.$color } }
for (const [key, value] of Object.entries(part.style)) {
style[key] = typeof value === "function" ? value(props) : value;
}
}
}
return <button className={[...classNames].join(" ")} style={style} />;
};
}When the component is rendered, the runtime passes the received props through all the dynamic functions, collects the resulting class names into a Set and CSS variable values into a style object, and forwards them as className and style props to the underlying DOM element.
Context
The yak theme context allows you to configure the theme of your yak application. It works for react-server components and normal react components, including full support for async server functions in Next.js 15+.
The first part of the puzzle is to export two different modules for the server and the client.
"./context": {
"react-server": {
"import": "./dist/context/index.server.js",
"require": "./dist/context/index.server.cjs"
},
"default": {
"import": "./dist/context/index.js",
"require": "./dist/context/index.cjs"
}
}For the client components, we use the default context from react.
"use client";
//
// This file is the client component (browser & ssr) version of index.server.tsx
//
import React, { ReactNode, createContext, useContext } from "react";
export interface YakTheme {}
/**
* The yak theme context
* @see https://github.com/DigitecGalaxus/next-yak/blob/main/packages/next-yak/runtime/context/README.md
*/
const YakContext = createContext<YakTheme>({});
/**
* Returns the current yak theme context
*
* @see https://github.com/DigitecGalaxus/next-yak/blob/main/packages/next-yak/runtime/context/README.md
*/
export const useTheme = (): YakTheme => useContext(YakContext);
/**
* Yak theme context provider
*
* @see https://github.com/DigitecGalaxus/next-yak/blob/main/packages/next-yak/runtime/context/README.md
*/
export const YakThemeProvider = ({
children,
theme = {},
}: {
children: ReactNode;
theme?: YakTheme;
}) => <YakContext.Provider value={theme}>{children}</YakContext.Provider>;For the server components, we use a module that exports exactly the same API as the one for client components, but with a different implementation where we use the cache function from react to cache the result of the context.
//
// This file is the react-server component version of index.tsx
//
// @ts-ignore - in the current @types/react "cache" is not typed
import React, { ReactNode, cache } from "react";
import { YakThemeProvider as YakThemeClientProvider } from "./index.js";
// the following import might be changed by
// the user config in withYak to point to their own
// context
import { getYakThemeContext } from "next-yak/context/baseContext";
export const useTheme = cache(() => getYakThemeContext());
export const YakThemeProvider = ({ children }: { children: ReactNode }) => {
return <YakThemeClientProvider theme={useTheme()}>{children}</YakThemeClientProvider>;
};The getYakThemeContext function is imported from the user's yak.context.ts file. This file is a regular typescript file that exports a function that returns the theme context. You can use every api that works with server components like headers or cookies and use them
to determine your values for the theme context.
import { cookies } from "next/headers";
export async function getYakThemeContext() {
const cookieStore = await cookies();
return {
highContrast: cookieStore.get("highContrast")?.value === "true",
};
}
declare module "next-yak" {
export interface YakTheme extends Awaited<ReturnType<typeof getYakThemeContext>> {}
}The second part of the puzzle is that the withYak function changes to what file webpack resolves the context so that
it points to the individual yak.context.ts file in the root directory.
// With the following alias the internal next-yak code
// is able to import a context which works for server components
const yakContext = resolveYakContext(yakOptions.contextPath, webpackConfig.context);
if (yakContext) {
webpackConfig.resolve.alias["next-yak/context/baseContext"] = yakContext;
}