Skip to content

Replacing Babel with OXC in Storybook

October 25, 2026 · 20 min read

Contents

"I believe we'll have very little appetite for owning two indexers instead of one."

That was not the answer I expected to my proposal to speed up Storybook's CSF indexing with OXC. I thought keeping the existing Babel implementation around was the safe choice. The maintainers saw it the other way: that choice would raise the maintenance cost going forward.

The work started at the end of September 2026 as a fix for a simple problem: the same story file was being parsed twice. I opened PR #36468 to remove the duplicate parse, and then proposed a fast indexing path built on OXC in Discussion #36467. After some discussion, though, the goal shifted from optimizing indexing to removing Babel and Recast from the CSF processing layer altogether.

Expanding the scope introduced challenges I hadn't anticipated. Code that passed the existing tests could still change the value of a template literal or the meaning of an array config. This post covers the choices I made while working on PR #36613, and what I missed before review caught it.

DateWork
Sep 27Proposed Discussion #36467, submitted PR #36468
Oct 6Submitted PR #36613
Oct 13Duplicate-parsing PR merged
Oct 15–20Differential review, regression fixes, joint work with the maintainers
Oct 20–21OXC migration PR and the split-out PR #36861 merged

1. It started with duplicate parsing

In Storybook, stories are written in CSF (Component Story Format) files. For example:

import type { Meta, StoryObj } from '@storybook/react';

import { Button } from './Button';

const meta = {
  component: Button,
  title: 'Example/Button',
} satisfies Meta<typeof Button>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Primary: Story = {
  args: {
    label: 'Button',
  },
};

Storybook parses this file to extract each story's ID, name, and metadata, and builds an index from them. The same CSF processing logic (csf-tools) is also used to show the original source in Docs and to turn stories into Vitest tests. At the time, all of this ran on Babel.

Most of the transform time was parsing

The first place I looked was the CSF plugin in addon-docs. When I timed the plugin's transform across the repository's 706 story files, about 92% of the total time went to Babel parsing. The transform logic itself was light; nearly all the time was spent "turning code into an AST."

Reading the code, I found out why. In Vite, this plugin runs in the pre phase, so it usually receives the original story file as is. Yet the plugin was parsing the same source with Babel twice:

  • once as the target to attach Docs information to
  • once as a "clean original" to read JSDoc descriptions and the original source (originalSource) from

If there was no need to build two ASTs from the same input, the parsing cost could drop accordingly.

What I checked before removing the second parse

Still, the two ASTs couldn't be merged unconditionally. The default enrichment only appends statements to the end of the file and doesn't modify existing story nodes, so as long as the code matches the file on disk, the parsed result can double as the original.

The user-registered experimental_enrichCsf hook, on the other hand, can read the original during the transform. React's code-examples enricher actually did exactly that. So I made the reuse conditional on both of the following:

  • the code passed in is identical to the original file on disk
  • no experimental_enrichCsf hook is registered

In Webpack, the loader runs after babel/swc, so the code never matches the file on disk; I left that path unchanged.

Runtime was cut in half

PR #36468 delivered the reduction I expected:

  • Plugin transform time: about 8.4s -> 4.2s (average of 5 runs)
  • Generated code and source maps identical to before for all 706 files

During review, a maintainer separately confirmed that the code and source maps were byte-for-byte identical across 705 story files in several transform modes, and the PR was merged on October 13.

In the OXC migration that followed, I kept submitting comparisons against the existing output alongside the performance numbers.

2. The RFC: "Not replacing Babel"

Once the duplicate parse was gone, I wanted to try swapping out the parser itself.

The candidate was OXC, a JavaScript/TypeScript toolchain written in Rust. Storybook was already using oxc-parser to build the module graph for change detection (#34625). Not having to add a new dependency was a big deal.

In the initial RFC #36467, I limited the scope of the change to indexing:

  • A fast path that indexes only statically analyzable story files with OXC
  • Unsupported shapes return null and fall back to the existing Babel CsfFile
  • Non-goal: replacing Babel/CsfFile. Transforms and writes keep using Babel
  • The alternative I considered, "fully replacing Babel in CsfFile," was rejected as much larger and riskier

The PoC results looked like this:

ItemResult
Files handled by the fast path680 of 706 (96.3%)
Mismatches against Babel output0
Internal UI index.json (1,768 entries)Byte-for-byte identical
Isolated CSF indexing benchmark~4.5x
storybook index end-to-end7.97s -> 5.95s (~25% reduction)

I also made the fallback for the remaining 26 files deterministic, so the same files would drop out for the same reasons on every run. Under risks, I listed "drift between the two paths" and "the cost of maintaining one more read-only code path."

The reply: don't maintain two indexers

The first response was not what I expected. Maintainer Sidnioulz replied:

I believe we'll have very little appetite for owning two indexers instead of one, though. … My personal opinion is that "Replacing Babel in CsfFile entirely" would be a great goal for this RFC. It's definitely a bigger piece of work but it's also a bigger net benefit and it avoids having to still preserve Babel!

The "drift between the two paths" I had written down as a risk, the maintainer saw not as a risk but as a reason the approach couldn't be accepted. Once CSF 4 becomes the default in Storybook 11, PRs would pile up against the static extraction code used for the index, and each time the two implementations would need to be kept in sync.

I didn't expand the scope right away. First, I asked whether anyone was already working on replacing Babel in CsfFile. The bigger the task, the more important I thought it was to make sure it didn't overlap with someone else's work.

Then Michael Shilman, who created Storybook, wrote that from a product standpoint it made complete sense, and added: "At that point can we just remove Babel entirely? If not, what's blocking us? I don't want to get into a situation where we're supporting both long term."

Sidnioulz asked me to go ahead with a PoC, noting that there might be requirements that weren't visible at first, and that the team would leave them in the thread as they came to mind.

At first, I thought touching as little of the existing code as possible was the safe option. In the PoC, in fact, OXC handled only 680 of the 706 files and left the rest to Babel. That approach meant fewer edge cases to deal with right away and seemed to carry less risk of breaking existing behavior.

But every time an indexing rule was added, both implementations would have to change together. The conditions for handing files OXC couldn't process over to Babel would also need ongoing maintenance. The immediate PR gets smaller, but the problem of two code paths drifting apart never goes away.

So I changed my criterion. What mattered was not how small I could make this change, but whether two implementations would have to be maintained after it was merged. Fully replacing Babel meant more upfront work and a heavier verification burden, but if it succeeded, the CSF processing rules would live in one place. That didn't mean getting rid of Babel for compiling user code, though. I limited the scope to the CSF processing path centered on csf-tools.

So before starting the implementation, I laid out the scope and measurement plan again and posted it to the Discussion. The goal was to give the team a chance to flag problems early.

  • What gets replaced: the read path, the write path, transforms, and one external consumer to validate the API. The riskiest part, the write path, goes first.
  • How it's measured: the existing test suite passes unchanged, every snapshot change is called out explicitly, and index.json is verified to be byte-for-byte identical.
  • Known limitations: formatting of inserted code, no Flow support, and breaking changes from exposing the AST.
  • Out of scope: Babel as a compiler for user code (babel-loader and the like).

I shared the PoC results and the PR link in the same thread, and Michael Shilman replied, "These are amazing numbers".

3. Moving the work Babel used to do

Removing Babel wasn't just a matter of swapping the parser. csf-tools actually relied on Babel for three things:

What Babel didAfter the replacement
@babel/parser - parsingoxc-parser (ESTree)
@babel/traverse - NodePath, scope.getBinding()A hand-written scope analyzer (estree/scope.ts)
recast - reprinting after AST editsA span-based text editor (estree/editor.ts, MagicString)

Porting scope analysis and code editing took far more time than switching the parsing API.

Implementing scope analysis from scratch

OXC's JavaScript API doesn't expose scope information. Yet Storybook relied on Babel's scope analysis all over the place, taking it for granted. The server-code removal plugin for the TanStack framework is a good example: in createServerFn().handler(fn), if fn is an identifier that's used only once, the plugin deletes its declaration.

In Babel, it took three lines:

// Before: Babel NodePath

if (t.isIdentifier(handlerArg)) {
  const binding = path.scope.getBinding(handlerArg.name);
  if (binding && binding.referencePaths.length === 1) {
    binding.path.remove();
  }
}

That single binding.path.remove() line hid a lot. What needs to be deleted depends on whether the declaration is a function or a const, whether it's one of several declarators as in const a = 1, b = 2, and whether it sits inside an export statement. Babel handled all of that for you at the AST level.

Moving to OXC, I had to handle the deletion conditions myself. After confirming there's only one reference, a function or class gets its whole declaration removed. Variable declarations are a bit more involved: removing just a from const a = 1, b = 2 means dealing with the comma too, and a declaration wrapped in export has yet another boundary to consider. In effect, I was spelling out in code the behavior that had been sitting behind Babel's one-line binding.path.remove().

scope.ts became an 850-line analyzer that reproduces the semantics of Babel's getBinding / constant / referencePaths. I verified it with differential comparison: 99,828 bindings from 3,717 files in the repository were compared against Babel, with zero mismatches. I also pinned Babel-specific rules in scope.test.ts, for example: type references count as references, export counts as a reference, and a function inside a loop body is not constant.

Even so, review found gaps. I'll come back to that in section 5.

Editing the original text instead of using Recast

Recast reprints the entire file after the AST is modified. It mostly preserves the original formatting, but not perfectly. During review, we actually found a bug on the existing next branch: when Recast reprinted a file, it changed \ line continuations and template literals in sibling nodes it hadn't even touched.

The alternative was to fix only the necessary ranges in the original string, instead of modifying the AST and printing it again. SourceEditor holds the original text and uses MagicString to record insertions, deletions, and replacements on specific spans. Since unedited parts are never reprinted, the original whitespace and line breaks are preserved exactly.

This choice came with a clear cost. With Recast, when you deleted or moved a node, the printing step took care of the surrounding syntax to some extent. With span editing, you have to decide for yourself about the comments in front of a node being deleted, the commas next to array elements, and the outer boundaries of parentheses. It's better for preserving the original formatting, but string manipulation that ignores syntax is actually more dangerous. I chose MagicString not because editing was easier, but so that code I didn't change would stay untouched.

In exchange, I had to do what Recast used to do: match the indentation of inserted code to its surroundings, follow the quote style, and get the boundaries of commas, parentheses, and comments exactly right. Changing spans and building a safe code editor turned out to be entirely different problems, and that difference showed up plainly in review.

4. A first PR that passed the tests

I opened PR #36613 on October 6. The first measurements, on 587 story files, showed 5.1x for indexing, 4.8x for Docs enrichCsf, and 3.2x for vitestTransform, with zero differences from Babel's output in all three.

Working from a fork, I couldn't assign reviewers or add labels, so I asked the maintainers for review and the necessary labels in a PR comment. I split the commits by area so they could be read in order, and noted in the description that, once approved, it could be merged as three stacked PRs.

Early in the review, Kasper Peulen asked:

How do you get rid of recast? And keep the indentation? I heard it can be done with magic-string

I answered that inserted code copies the indentation of the line it goes into (reindent) and follows the file's quote style, and that the save-story and codemod snapshot tests passing unchanged was the best evidence that formatting was preserved.

At the time, I treated passing the existing snapshots as fairly strong evidence. But it guaranteed nothing about inputs the snapshots didn't cover. In particular, I missed cases like whitespace or parentheses inside strings, where the code still parses fine but its meaning changes. The next round of review revealed exactly that.

5. Three regressions found in review

The next day, Kasper's review came in. It opened by saying the direction was right and the read side was impressive, but the verdict was Changes requested.

Kasper fed the same inputs into the compiled core/dist of next (Babel) and of my branch (OXC), and compared the results.

Kasper tested a substantial range of inputs:

  • ~1,000 story files × 8 story transforms, 5,397 save-story cases
  • ~15,000 automigration runs, ~21,600 ConfigFile manipulations
  • A 76-file adversarial corpus

The read path passed nearly all checks. Index entries were identical, and the Vite csf-plugin source maps were actually better than next (0 mis-mapped tokens, versus 147,000 on next). The problem was the write side. Some changes could alter the meaning of user code without any error.

From that point on, I didn't stop at checking whether the tests passed; I started by asking why the results should be the same. A parse error is easy to spot, but when a string value or a config object changes silently, it's hard to notice until a real user runs into it. The three cases below show that difference well.

Case 1: A template literal's value changes

reindent added indentation to every line of the code being inserted. Lines inside template literals were no exception.

// A value inserted into an object via set(['code'], ...)
{ code: `line1
line2` }

// When read back
// Babel: "line1\nline2"
// OXC:   "line1\n    line2"  // the indentation ends up in the string

Whitespace inside a template literal is part of the string's value. Adding indentation line by line had no way of telling the difference.

For the fix, I parse the code to be inserted first, to find regions where line breaks affect the value, such as template literals and strings. reindent now leaves whitespace inside those regions alone. It went from adjusting indentation by looking only at lines of text to checking the code's syntax.

The same problem existed in three other helpers that re-indented code line by line (Vitest config wrapping, React source output, and others). In the end, I consolidated them all onto this single reindent.

Case 2: A single parenthesis changes an array

With its default setting (preserveParens: false), OXC doesn't include outer parentheses in a node's span. But the editor used node spans as its boundaries.

// Original
export default { addons: [(cond ? 'a' : 'c')] };

// After running appendValueToArray(['addons'], 'b')
// Babel: [(cond ? 'a' : 'c'), 'b']
// OXC:   [(cond ? 'a' : 'c', 'b')]
// Because of the comma operator, this effectively becomes ['b']

It's not a syntax error, and nothing throws. The config file just quietly takes on a different meaning. I changed list edits to use the node's outer span, parentheses included (outerSpan), and this exact input became a regression test in editor.test.ts.

Case 3: Who owns a comment?

The csf-2-to-3 codemod converts the CSF 2 Template.bind({}) pattern into CSF 3 objects and deletes the Template declaration. The problem was the comment above it.

/** Shared template */
const Template = (args) => <Button {...args} />;
export const Primary = Template.bind({});
Primary.args = { label: 'A' };
// Incorrect output: Template's comment is left on Primary
/** Shared template */
export const Primary = {
  args: { label: 'A' },
};

Template's JSDoc was left behind and ended up attached to Primary. In Docs, Primary's description becomes "Shared template." Had it been // @ts-expect-error or // eslint-disable-next-line, it would apply to the next export. In fact, the repository's Zoom.stories.tsx picked up five TS2578 errors after migration.

In an AST, comments aren't nodes; they're text between nodes. Babel/Recast attached comments to nodes, so deleting a node removed its comments too. With span editing, you have to decide for yourself "which statement does this comment belong to?" I changed it so that deleting a statement also deletes the comments attached in front of it, while deleting the first statement keeps the file header (license and so on).

Gaps in the scope analysis

In section 3, I wrote that 99,828 bindings matched, but review still found gaps. Differential comparison can't catch patterns that don't exist in the repository's code.

let token;
export function setSession(s) {
  token = s.token;
}
export const getData = createServerFn().handler(async () => token);

// Babel: keeps the let token declaration
// Initial OXC implementation: deletes the declaration -> ReferenceError in setSession()

There were other issues too, such as parameter default values being resolved to declarations in the function body, and parameter decorators not being visited.

The more dangerous part was how unknown TypeScript nodes were handled. The original scope.ts treated any TS node it didn't recognize as type-only. If an OXC upgrade renamed a node or introduced a new one, references would silently be undercounted, and for dead-code elimination that's the most dangerous direction to be wrong in, because it can delete a live import. So I made it fail closed.

Now it throws when it hits an unknown node. Stopping was safer than deleting a declaration that's actually in use based on a wrong judgment.

Other fixes

The review contained many more items besides these:

  • The Webpack/Rspack loader discarded incoming source maps -> story files in every webpack project mapped to the wrong lines
  • Parsing without a filename assumed TSX -> the generic arrow function <T>(x: T) => x in .ts files failed to parse, and Vite automock mocked nothing
  • OXC doesn't report semantic errors by default -> files were indexed even when export const A was declared twice (fixed with showSemanticErrors: true)
  • Code inserted into CRLF files always used \n -> mixed line endings
  • Dropping Flow support needed to be an explicit breaking change -> MIGRATION.md and a dedicated error message for // @flow files

6. Fixes and verification after the review

I started by acknowledging the mistake. My first reply to the review began like this:

My earlier reply about indentation was too confident: reindent did shift lines inside template literals, as you found.

I shared the order I'd tackle the fixes in: parser, editor, source maps, per-package regressions, scope analysis, then migration docs. If the input is being parsed wrong, there's little point fixing the editor first; you can't trust the results.

I split the fix commits by review comment wherever possible, and left the corresponding commit hash on every inline thread. In the first round, 23 fix commits went on top of the original 17. For items without an inline thread, I listed the commits item by item in a PR comment. The aim was to let reviewers see at a glance which commit addressed which comment.

Testing for equivalence without Babel

The review also changed how I tested. At first I thought, "Why not keep the Babel implementation around for tests and keep comparing against it?" Kasper suggested something different.

I pinned 27 inputs taken from the repository and saved the output of the last Babel next as golden.json. It records the values real callers use: index entries, _stories and _meta, test names and IDs, and scope bindings. For comparison I used toEqual rather than snapshots, which are easy to update, to keep anyone from accidentally updating a snapshot and shifting the baseline along with it.

These inputs and their expected values live together in csf-tools/__corpus__/. Twenty-six match Babel exactly, and the remaining one (a generic arrow function in a .ts file) has its own expected value, since what used to be a Babel parse error is now indexed.

Now, even after the Babel implementation is deleted, OXC upgrades can be checked against the same inputs and expected values. Intended differences are recorded separately in the golden file, and anything else is treated as a regression.

Working together on the same branch

Near the end of the third round, Kasper asked whether it would be OK to push directly to my branch, since the remaining follow-ups would go faster as commits than as back-and-forth in review threads. In return, Kasper promised not to force-push and asked me not to rebase.

I accepted right away. As promised, from then on I kept up with next only through merge commits instead of rebasing, and said so explicitly. With two people working on the same branch, agreements about how to handle history mattered as much as the code. After that, wrapping up review comments, restructuring the editor, and performance optimization all continued on the same branch. On supported environments (64-bit Node 22 or later, excluding Windows), we also used OXC's raw transfer to reduce AST serialization cost, and compared the output before and after the change.

On October 20, the approving review arrived.

Amazing work, let's go!!

7. Changes that affected downstream packages

The change didn't stop at csf-tools. Docs, Vitest, CLI automigrations and codemods, docgen for React, Vue, Angular, and Web Components, TanStack's server-code removal, mock extraction in both builders, and the sandbox scripts: every place that used Babel was migrated. Babel remains only where user code is compiled (@storybook/nextjs, ember).

The public API changed too. CsfFile, CsfObject, and CsfEnricher now expose ESTree nodes instead of Babel nodes, and storybook/internal/babel is gone. Some changes are visible to users as well:

  • docs.source.originalSource is now the original text as written, not code regenerated by Babel
  • In automigration output, code that wasn't touched keeps its formatting
  • Error messages use the message (line:column) format instead of Babel code frames
  • Flow story files are no longer supported

Since we decided not to add a compatibility layer, I went directly to the projects affected. I opened PRs against 10 community packages that read storybook/internal/babel or CsfFile._ast (storybook-rsbuild, msw-storybook-addon, solidjs storybook, react-spectrum, and others), moving them to the public csf-tools API. Until those packages ship releases, the corresponding sandboxes are temporarily excluded from CI, with comments in sandbox-templates.ts linking the PRs and stating the conditions for re-enabling them.

It was an internal API, but external packages were actually using it. Merging the core change alone wasn't going to finish the job.

8. One-line fix, separate PR

Along the way, I also fixed a bug unrelated to OXC. The Open Service runtime creates a timer to wait for a reply after sending a sync request, and in Node.js this timer held the event loop open, so one-shot commands that had finished their work wouldn't exit until the timer ran out.

silenceTimer.unref?.();

With unref(), the timer keeps working as normal while other work keeps the process alive, but it no longer prevents exit when it's the only thing left. It's the same pattern the repository's file-lock.ts used for its heartbeat.

It was originally part of the main PR, but Kasper's review pointed out that "it's an unrelated commit, so it could go in separately", so I split it into PR #36861. Buried among more than 100 commits, the one-line fix was merged within a day, along with a single test, once it stood on its own.

9. Final measurements

Setup: the repository's 740 story files (1.45MB) and 80 config files, M1 Pro, median of 45 runs per task.

TaskBabelOXCSpeedup
CSF indexing1,086ms128ms8.5x
Docs source processing (enrichCsf + source maps)1,183ms172ms6.9x
Vitest transform1,192ms288ms4.1x
10 config edits138ms90ms1.5x

Project-level numbers (for the internal Storybook UI):

MetricBeforeAfter
storybook index1.65s0.84s
Dev server smoke test (median)4.76s3.87s
Full build~38.9s~36.0s
npm package, unpacked17.75MiB15.50MiB (-13%)
npm package, packed5.29MiB4.89MiB (-8%)

An 8.5x microbenchmark doesn't make the full build 8.5x faster; a build does a lot more than parse CSF. Even so, indexing time was cut in half. Part of that came not from parser speed but from no longer loading Babel at all. Babel takes about 64ms to load per process or worker, while oxc-parser takes about 5ms.

On correctness:

  • Zero differences from Babel's output across all three tasks: indexing, enrichCsf, and vitestTransform
  • Internal UI index.json: 1,766 entries, byte-for-byte identical
  • Scope analysis: 99,828 bindings matched
  • 48 projects compile and pass check; 14,643 unit tests pass (the 4 local failures are environment-dependent issues in files that weren't changed)

10. Looking back

Initially, I thought eliminating duplicate parsing would be enough. Then it seemed like adding one more fast indexing path with OXC would do. What actually got merged wasn't a second indexer, but a change that replaced a CSF processing layer that had depended on Babel and Recast.

The problems I wrestled with longest weren't about parser speed either. They were questions like how much of a deleted declaration's comments to remove, whether parentheses belong in a span, and how to preserve whitespace inside template literals. The old implementation handled these without anyone spelling them out. Only after moving to a new implementation did I realize they were, in effect, compatibility requirements.

The first editor I wrote passed the existing tests, but it wasn't safe enough. Without Kasper's differential review, some of those regressions would have shipped. From then on, I fed the same inputs into both implementations, compared the results, and turned every problem I found into a regression test. And so that comparisons remain possible after Babel is removed, I kept the last Babel output as a pinned golden file.

Sidnioulz and Michael Shilman made me rethink the goal before I started implementing, and Kasper didn't stop at finding the gaps in the implementation; by the end, Kasper was pushing fixes to the same branch. This was not a case of code I prepared on my own getting merged as is.

In the end, CSF indexing became 8.5x faster, and real indexing time dropped by about half. But if I did this again, the first thing I'd prepare, before any performance measurement, would be a plan for which inputs to compare the existing behavior against, and how far to take that comparison. As the small initial optimization grew into a large replacement, that was where I spent the most time.