The Preset System: How Storybook Composes Configuration from Dozens of Sources
Prerequisites
- ›Article 1: Architecture Overview
- ›Understanding of JavaScript module resolution
- ›Familiarity with functional composition patterns (reduce/fold)
The Preset System: How Storybook Composes Configuration from Dozens of Sources
If the three-environment architecture (covered in Part 1) is Storybook's skeleton, the preset system is its nervous system. Every piece of configuration in Storybook — from babel transforms to Vite plugins to which stories appear in the sidebar — flows through a single composable pipeline. A preset is simply a module that exports named functions. Each function receives accumulated configuration and returns a transformed version. Stack enough of these together in the right order and you get the full Storybook experience.
Understanding presets is essential for anyone building addons, customizing their Storybook setup, or contributing to the core. Let's trace the system from concept to implementation.
What Is a Preset?
At its most basic, a preset is a JavaScript module that exports named functions or values. Each export corresponds to a configuration key — core, stories, viteFinal, features, babel, and so on. When Storybook needs the value for a given key, it walks through all loaded presets in order, passing the accumulated value to each preset's function for that key.
The types that drive this are defined in the core:
// Simplified from storybook/internal/types
interface LoadedPreset {
name: string;
preset: Record<string, any>; // The module's exports
options: Record<string, any>; // Options passed to this preset
}
interface Presets {
apply: (extension: string, config?: any, args?: any) => Promise<any>;
}
The apply method is the only public interface. Callers never interact with individual presets — they ask for a configuration key and get back the fully composed result.
The Two-Pass Loading Strategy
As we saw in Part 1, buildDevStandalone() loads presets twice. This isn't redundancy — it's a necessity. The first pass determines the builder (Vite or webpack), because builders can introduce override presets that must be loaded after all other presets.
code/core/src/core-server/build-dev.ts#L162-L180
flowchart TD
Start[buildDevStandalone] --> Pass1["First Pass: loadAllPresets()"]
Pass1 --> |"isCritical: true"| Determine["Determine builder from core config"]
Determine --> Resolve["Resolve builder + renderer packages"]
Resolve --> Pass2["Second Pass: loadAllPresets()"]
Pass2 --> |"Full preset chain"| Apply["Apply presets for all config keys"]
subgraph "First Pass Presets"
FP1[Framework preset]
FP2[Override preset]
end
subgraph "Second Pass Presets"
SP1[common-preset.ts]
SP2[Manager builder presets]
SP3[Preview builder presets]
SP4[Renderer preset]
SP5[Framework preset]
SP6[User main.ts]
SP7[Override presets]
end
The first pass creates a temporary channel (with stub transports that log errors if used) and sets isCritical: true, which causes preset loading failures to throw rather than warn. It loads only the framework preset and the common override preset.
After the first pass determines the builder, the second pass loads the complete preset chain in this order:
common-preset.ts— base defaults- Manager builder core presets
- Preview builder core presets
- Renderer preset (e.g.,
@storybook/react/preset) - Framework preset (e.g.,
@storybook/react-vite/preset) - User's
main.ts(the Storybook config file) - Override presets (from builders + common override)
code/core/src/core-server/build-dev.ts#L246-L260
This ordering is crucial: later presets in the chain can override earlier ones. Override presets run last, giving them the final say on any configuration value.
The applyPresets Reduce Chain
The core of the preset system is applyPresets() — a reduce operation over loaded presets:
code/core/src/common/presets.ts#L265-L316
flowchart LR
Init["Initial Value"] --> P1["Preset 1 (common)"]
P1 --> P2["Preset 2 (builder)"]
P2 --> P3["Preset 3 (renderer)"]
P3 --> P4["Preset 4 (framework)"]
P4 --> P5["Preset 5 (user main.ts)"]
P5 --> P6["Preset 6 (override)"]
P6 --> Result["Final Config"]
The logic handles three cases for each preset's contribution to a configuration key:
- Function — the preset's export is called with
(accumulatedValue, combinedOptions)and its return value becomes the new accumulated value. This is the most common and powerful form. - Array — concatenated with the accumulated array.
- Object — shallow-merged with the accumulated object.
Here's a concrete example. When Storybook needs viteFinal (the hook that lets you modify Vite config), it calls presets.apply('viteFinal', baseViteConfig). Each preset that exports a viteFinal function gets to transform the config:
// In react-vite's preset.ts
export const viteFinal = async (config, { presets }) => {
const plugins = [...(config?.plugins ?? [])];
// Add docgen plugins...
return { ...config, plugins };
};
The combinedOptions argument provided to each function merges storybookOptions, the global args, and the preset's own options. It also includes a nested presets object with its own apply method, allowing presets to recursively query other configuration values.
Tip: When writing a
viteFinalorwebpackFinalhook, always spread the incoming config ({ ...config, ... }) rather than returning a new object. Dropping the accumulated config is the most common source of preset-related bugs.
Addon Resolution
Before presets can be loaded, addons need to be resolved from package names to actual file paths. The resolveAddonName function handles this mapping:
code/core/src/common/presets.ts#L68-L116
flowchart TD
Input["Addon name string"] --> IsPreset{Ends with /preset?}
IsPreset -->|Yes| ReturnPreset["Return as preset type"]
IsPreset -->|No| Probe["Probe for /preset, /manager, /preview"]
Probe --> HasFiles{Found any?}
HasFiles -->|Yes| Virtual["Return as 'virtual' type with:
- presets[] (from /preset)
- managerEntries[] (from /manager)
- previewAnnotations[] (from /preview)"]
HasFiles -->|No| TryDirect{Direct resolve?}
TryDirect -->|Yes| ReturnPreset
TryDirect -->|No| Undefined["Return undefined (skipped)"]
This is the three-files-per-addon convention. An addon can provide up to three entry points:
/preset— Node.js code that modifies Storybook's build configuration/manager— Browser code that registers panels, tools, and tabs in the Manager UI/preview— Browser code that adds decorators, parameters, or globals to the Preview iframe
The resolution returns a "virtual" addon type that packages all three into a single structure. This is how addons: ['@storybook/addon-docs'] in your main.ts gets expanded into the right combination of presets and entry files.
The Common Preset: Default Configuration
The common preset is the base layer — the defaults that every Storybook instance starts with before any framework, addon, or user configuration is applied.
code/core/src/core-server/presets/common-preset.ts#L48-L236
It provides defaults for a wide range of configuration keys:
| Key | Default | Purpose |
|---|---|---|
features |
Actions, controls, interactions enabled | Feature flags for built-in addons |
typescript |
react-docgen, no type checking |
TypeScript/docgen behavior |
babel |
Browser target overrides for story files | Babel transform defaults |
experimental_indexers |
CSF indexer (/(stories|story)\.(m?js|ts)x?$/) |
Story file discovery |
core |
Channel options, telemetry settings | Core runtime configuration |
storyIndexGenerator |
Async singleton generator | Story index creation |
The features preset at lines 205–221 is particularly interesting — it's the master switch for built-in features like actions, controls, backgrounds, viewport, and more. Each of these was once a separate addon that required explicit installation; now they're built-in and toggled via feature flags.
The experimental_serverChannel function at lines 271–288 is where all server-side channel handlers get initialized — file search, story creation, ghost stories, open-in-editor, and telemetry channels.
Frameworks as Thin Preset Wrappers
One of the cleanest design decisions in Storybook is how framework packages are implemented. Take @storybook/react-vite:
code/frameworks/react-vite/src/preset.ts#L1-L50
The entire framework preset does two things:
- Declares the builder and renderer via the
coreexport (lines 5–8) - Customizes the Vite config via
viteFinalto add React-specific plugins (docgen, etc.)
flowchart LR
FW["@storybook/react-vite"] --> Core["core: { builder: builder-vite, renderer: react }"]
FW --> VF["viteFinal: adds docgen plugins"]
subgraph "Resolved Chain"
BV["@storybook/builder-vite/preset"]
RR["@storybook/react/preset"]
end
Core --> BV
Core --> RR
This means adding a new framework is a matter of declaring which builder and renderer to compose, plus any framework-specific build customizations. The preset chain handles the rest.
Tip: When debugging framework-specific build issues, check your framework's
preset.tsfirst. TheviteFinal(orwebpackFinal) hook there is the most common place where framework-specific Vite/webpack plugins are added. If something is missing or misconfigured, that's usually where to look.
The loadPreset Recursion
Presets can themselves declare sub-presets and sub-addons. The loadPreset function handles this recursion:
code/core/src/common/presets.ts#L156-L243
When a preset module is loaded, its addons and presets arrays are resolved recursively. The ordering is deliberate: sub-presets and sub-addons are loaded before the parent preset itself. This means the parent can override anything its children set. Your main.ts file — which is loaded as a preset — gets the final say over any addon or framework configuration because it's processed last in its level of the chain.
The function also supports a disabledAddons list (from build.test.disabledAddons), allowing test builds to skip heavy addons for faster execution.
Bridging to Channels
The preset system is a Node.js–side concern — it runs during server startup to compose the configuration that drives both the build and runtime. But once the builds complete and the browser loads, a different composition mechanism takes over: the channel system. In the next article, we'll explore how the Channel class, PostMessage transports, and WebSocket connections enable the three environments to communicate in real time, forming the protocol that holds the entire Storybook experience together.