Storybook offers key advantages for component development, especially when building a design system. It addresses common challenges out of the box and streamlines your workflow. However, the abstraction that Storybook offers can obscure critical insights into component behaviour in real-world deployment scenarios, particularly with server-side rendering (SSR).
Server-side rendering is the process of generating HTML markup on the server before transmitting it to the client. This approach reduces the client-side computational load on the browser during page rendering, thereby enhancing performance, search engine optimisation (SEO), and related metrics. However, server-side rendering introduces added complexity, particularly when integrating with Web Components, making setup and ongoing maintenance more challenging.
Let's take Lit, a Web Component framework that my team uses as an example. An experimental server-side rendering feature exists that relies on the declarative shadow DOM, but for correct hydration, components must be adhere to certain lifecycle constraints. For instance, if certain rendering logic is placed in improper lifecycle callbacks, a component may render as expected in Storybook but fail during server-side rendering. This results in significant time spent debugging discrepancies between development and production. Tooling for static analysis, such as linting and lifecycle enforcement, is relatively immature due to the feature's experimental status, and replicating server-side rendering-specific conditions in Storybook is currently not feasible.
As my team relies heavily on Storybook for development and documentation, I wanted to find a low-cost way to provide visibility into how our components render with Next.js. Our bandwidth is limited, so I didn't want to set up a number of manually curated examples as we're already doing that in Storybook. Instead, I wanted to find a way to mirror exactly what we have in Storybook in Next.js so we can easily identify any discrepancies and fix them before they reach production.
Leveraging CSF
One of my favourite changes to Storybook in recent memory was the switch towards the Component Story Format (CSF). This format standardises how "stories" are written across similar applications to Storybook. If you're unfamiliar with the term "story", they represent a single rendering of a component; for example, a button's primary colour could be a story, and its secondary colour could be another; they are, in essence, common configurations, things your design system users would be looking for when they browse your catalogue of components.
As we're already writing CSF, what if we could combine those objects with the Custom Elements Manifest (CEM) to create an exportable representation of a story that can be rendered more directly within Next.js? The Custom Elements Manifest lists every component and its properties in our component library already. It should be relatively straightforward to use it to connect the dots between the CSF object and its associated component, thereby reconstructing the markdown that Storybook uses in its rendering. Here's the plan:
Parse through the Custom Elements Manifest using a custom plugin for @open-wc/analyzer.
Use the source path to find the component's associated story file. This works if you follow the common convention of colocating your stories with your components.
Import the exported CSF object and resolve any variables, merge the data with the component metadata from the Custom Elements Manifest.
Render the component in Next.js using the tag name, attributes and slot content from the enriched manifest.
Walking the AST
Let's start by writing the plugin. We’ll hook into the packageLinkPhase, which executes after the manifest has been fully assembled and provides access to the complete component metadata. Once activated, the plugin locates all .stories.ts files, parses each one, and merges the story args with the CEM’s attribute defaults and slot definitions.
The following code samples have been simplified for readability.
export default function cemStoryArgsPlugin(options = {}) {
const {
srcDir = 'src',
outDir = 'dist',
fileName = 'stories-manifest.json',
} = options;
return {
name: 'cem-story-args',
packageLinkPhase({customElementsManifest}) {
const srcPath = path.join(process.cwd(), srcDir);
const storyFiles = findStoryFiles(srcPath);
const storiesManifest = {
version: '1.0.0',
generatedAt: new Date().toISOString(),
totalComponents: 0,
totalStories: 0,
components: {},
};
for (const storyFile of storyFiles) {
const extracted = parseStoryFile(storyFile, srcPath);
if (extracted.component && extracted.stories.length > 0) {
// Find matching CEM component data
const cemComponent = customElementsManifest?.modules
?.flatMap(m => m.declarations || [])
?.find(d => d.tagName === extracted.component);
// Build default args from CEM attributes and slots
const defaultArgs = {};
const defaultSlots = {};
cemComponent?.attributes?.forEach(attr => {
if (attr.default !== undefined) {
defaultArgs[attr.name] = parseDefault(attr.default);
}
});
cemComponent?.slots?.forEach(slot => {
defaultSlots[slot.name] = '';
});
// Merge CEM defaults with story-specific overrides
const storiesWithDefaults = extracted.stories.map(story => ({
...story,
args: {...defaultArgs, ...story.args},
slots: {...defaultSlots, ...story.slots},
}));
storiesManifest.components[extracted.component] = {
tagName: extracted.component,
storyFile: extracted.file,
schema: {},
stories: storiesWithDefaults,
};
}
}
const outputPath = path.resolve(process.cwd(), outDir, fileName);
writeFileIfChanged(outputPath, JSON.stringify(storiesManifest, null, 2));
},
};
}
The core of the plugin is parseStoryFile, which uses TypeScript’s AST (Abstract Syntax Tree) to walk through each story file. It collects variable declarations first so it can resolve references, then looks for the meta export and each named story export:
function parseStoryFile(filePath, srcDir) {
const content = fs.readFileSync(filePath, 'utf-8');
const sourceFile = ts.createSourceFile(
filePath, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS,
);
const result = {
file: path.relative(srcDir, filePath),
component: null,
stories: [],
};
const variableDeclarations = new Map();
// Resolve imports from other files first
resolveImports(sourceFile, filePath, variableDeclarations);
// Collect local variable declarations
ts.forEachChild(sourceFile, node => {
if (ts.isVariableStatement(node)) {
node.declarationList.declarations.forEach(decl => {
if (ts.isIdentifier(decl.name) && decl.initializer) {
const value = evaluateNode(
decl.initializer, sourceFile, variableDeclarations,
);
variableDeclarations.set(decl.name.text, value);
}
});
}
});
// Find exported story constants
ts.forEachChild(sourceFile, node => {
if (
ts.isVariableStatement(node) &&
node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)
) {
node.declarationList.declarations.forEach(decl => {
if (
ts.isIdentifier(decl.name) &&
decl.initializer &&
ts.isObjectLiteralExpression(decl.initializer)
) {
const story = { name: decl.name.text, args: {}, slots: {} };
decl.initializer.properties.forEach(prop => {
if (ts.isPropertyAssignment(prop)) {
const propName = getPropertyName(prop.name, sourceFile);
if (propName === 'args' &&
ts.isObjectLiteralExpression(prop.initializer)) {
prop.initializer.properties.forEach(argProp => {
if (ts.isPropertyAssignment(argProp)) {
const key = getPropertyName(argProp.name, sourceFile);
const value = evaluateNode(
argProp.initializer, sourceFile, variableDeclarations,
);
// Slot args are suffixed with '-slot' by convention
if (key?.endsWith('-slot')) {
story.slots[key.replace('-slot', '')] = value;
} else if (key) {
story.args[key] = value;
}
}
});
}
}
});
result.stories.push(story);
}
});
}
});
return result;
}
The reason we need the AST instead of simply importing the story files is that they often contain Storybook-specific constructs, decorators and framework imports that aren’t resolvable outside of a Storybook context. By walking the AST, we can extract only the data we need (the arg objects) without executing the code.
The evaluateNode function converts AST nodes into plain JavaScript values. It recurses through objects, arrays, and literals, and can even resolve variable references that were collected earlier:
function evaluateNode(node, sourceFile, variableDeclarations) {
if (!node) return undefined;
switch (node.kind) {
case ts.SyntaxKind.StringLiteral:
return node.text;
case ts.SyntaxKind.NumericLiteral:
return Number(node.text);
case ts.SyntaxKind.TrueKeyword:
return true;
case ts.SyntaxKind.FalseKeyword:
return false;
case ts.SyntaxKind.Identifier: {
// Resolve variable references
const varName = node.text;
if (variableDeclarations.has(varName)) {
return variableDeclarations.get(varName);
}
return `{{${varName}}}`;
}
case ts.SyntaxKind.ObjectLiteralExpression: {
const obj = {};
node.properties.forEach(prop => {
if (ts.isPropertyAssignment(prop)) {
const key = getPropertyName(prop.name, sourceFile);
obj[key] = evaluateNode(
prop.initializer, sourceFile, variableDeclarations,
);
}
});
return obj;
}
case ts.SyntaxKind.ArrayLiteralExpression:
return node.elements.map(
el => evaluateNode(el, sourceFile, variableDeclarations),
);
default:
return sourceFile.text.slice(node.getStart(sourceFile), node.getEnd());
}
}
This is important because story files commonly extract shared arguments into variables or import them from helper files. For example, a story file might look like this:
import {sharedContent} from '../shared-args';
const meta: Meta = {
id: 'components-button',
title: 'Components/Button',
};
export default meta;
export const Primary: Story = {
args: {
variant: 'primary',
label: 'Click me',
'default-slot': sharedContent,
},
};
export const Secondary: Story = {
args: {
variant: 'secondary',
disabled: true,
'default-slot': '<span>Custom content</span>',
},
};
The plugin resolves the sharedContent import, walks the AST of the imported file, and inlines the value. After the plugin runs, the generated stories-manifest.json looks something like this:
{
"version": "1.0.0",
"totalComponents": 1,
"totalStories": 2,
"components": {
"my-button": {
"tagName": "my-button",
"storyFile": "components/button/button.stories.ts",
"schema": {
"slots": [{"name": "default", "description": "The button content"}],
"attributes": [
{"name": "variant", "type": "string", "default": "'primary'"},
{"name": "disabled", "type": "boolean", "default": "false"}
]
},
"stories": [
{
"name": "Primary",
"storyId": "components-button--primary",
"args": {"variant": "primary", "label": "Click me", "disabled": false},
"slots": {"default": "<div>Shared slot content from import</div>"}
},
{
"name": "Secondary",
"storyId": "components-button--secondary",
"args": {"variant": "secondary", "disabled": true},
"slots": {"default": "<span>Custom content</span>"}
}
]
}
}
}
With this manifest, you have everything needed to reconstruct the component in any environment. On the Next.js side, consuming it is straightforward. You can import the manifest and render each story’s component using its tag name, attributes and slot content:
'use client';
import React, { useMemo } from 'react'
import dynamic from 'next/dynamic'
import manifest from '@my-scope/components/stories-manifest.json'
/**
* Create a dynamic component with server-side rendering support using next/dynamic.
* The import path is derived from the tag name, so every component
* in the library is loadable without a manual registry.
*/
function createDynamicComponent(
tagName: string,
): React.ComponentType<Record<string, unknown>> | null {
return dynamic(
() => import(`@my-scope/components/${tagName}/${tagName}.js`),
{ssr: true},
);
}
export default function ComponentPreview({tagName, storyName}) {
const component = manifest.components[tagName];
const story = component?.stories.find(s => s.name === storyName);
// Create a next/dynamic wrapper that supports server-side rendering
const DynamicComponent = useMemo(
() => createDynamicComponent(tagName),
[tagName],
);
if (!story || !DynamicComponent) return null;
// Build slot content from the manifest
const slotContent = Object.entries(story.slots)
.map(([name, content]) =>
name === 'default'
? content
: `<span slot="${name}">${content}</span>`,
)
.join('');
return (
<DynamicComponent {...story.args}>
<span dangerouslySetInnerHTML={{__html: slotContent}} />
</DynamicComponent>
);
}
This gives us a 1:1 mirror of every Storybook story, rendered with Next.js and Server-Side Rendering. When a developer adds or modifies a story, we can reflect those changes in the Next.js test app and validate that it passes a series of tests.
<my-alert defer-hydration=""><template shadowroot="open" shadowrootmode="open"><style>
.my-alert {
background: blue;
color: white;
padding: 1rem;
border-radius: 4px;
display: flex;
}
</style><!--lit-part ZtaKLkGwWx4=--><!--lit-node 0--><div class=" alert alert-info alert-type-basic " role="alert">
<div class="alert-icon">
<!--lit-node 2--><slot name="icon" ></slot>
</div>
<div class="alert-content">
<!--lit-node 4--><slot name="header" ></slot>
<slot></slot>
<!--lit-node 6--><slot name="footer" ></slot>
</div>
</div><!--/lit-part--></template><span slot="header"><div>Notification Title</div></span><span slot="footer"></span></my-alert>
Hitting view-source on the Next.js page shows all the correct markers of server-side rendering.
Conclusion
What started as a simple idea to mirror Storybook stories in Next.js has turned into something with multiple applications. For example, you could use it to power your own documentation site without relying on Storybook’s embeds, or you could use it to feed your own MCP server to provide AI agents with a live catalogue of your components and their capabilities. This approach creates almost a “headless” Storybook, allowing you to keep your development flow the same while powering multiple outputs from that same source of truth.
Top comments (0)