BuildWithMatija
  1. Home
  2. Blog
  3. React
  4. Radix to Base UI Migration Guide: Why Codemods Fail

Radix to Base UI Migration Guide: Why Codemods Fail

shadcn migration playbook: why codemods break, how primitives differ, and a safe progressive upgrade path

24th September 2026·Updated on:1st October 2026·MŽMatija Žiberna·
React
Radix to Base UI Migration Guide: Why Codemods Fail

⚛️ Advanced React Development Guides

Comprehensive React guides covering hooks, performance optimization, and React 19 features. Includes code examples and prompts to boost your workflow.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

Comments

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

Contents

  • What It Is
  • The Mental Model: Tailoring a Bespoke Suit
  • When To Use It
  • When NOT To Use It
  • Gotchas and Common Mistakes
  • A Migration Checklist
  • Conclusion
On this page:
Build with Matija logotip

Build with Matija

Complex B2B websites, headless CMS platforms, AI workflows, and internal systems designed and built with Next.js and Payload CMS.

Services

  • B2B Website Development
  • CMS Architecture Review & Platform Blueprint
  • Next.js + Payload Advisory
  • AI Integration & Implementation

Resources

  • CMS Hub
  • B2B Website Strategy
  • E-commerce Hub
  • Blog
  • Case Studies
  • About Matija

Payload CMS

  • Payload CMS Developer
  • Payload CMS Migration
  • Payload CMS Demos
  • All Payload CMS Resources

Discuss your project

Planning a rebuild, migration, application, workflow change, or platform decision? Start with the business problem and the system behind it.

Book a discovery callContact me →
© 2026Build with Matija•Vse pravice pridržane•Politika zasebnosti•Pogoji uporabe
BuildWithMatija
Get In Touch

I recently spun up a new project with shadcn and was genuinely surprised to see the notice announcing Base UI as the new default primitive library. Like many developers, the vast majority of my client projects over the last couple of years have been running on Radix UI. Naturally, my first instinct was to look into porting an existing project over. I found several community codemods and upgrade scripts promising automated one-click translation, but the moment I tested them on a real codebase, the reality set in: documentation codemods work fine on pristine starter templates, but they fail completely when faced with real-world tailored components. This guide covers what the Radix to Base UI shift in shadcn actually entails, why automated codemods fall short on customized primitives, and how to execute a safe, progressive upgrade.

What It Is

When shadcn announced Base UI as the default component library, the immediate reaction across the ecosystem was to look for a migration script. But shadcn is not an npm package you import as an immutable dependency; it is code copied directly into your repository. You own the component files in your project. You add variants, change class names, wire custom handlers, and inject project-specific helpers.

Because of this architecture, a traditional AST codemod cannot reliably transform your code. A codemod works well on pristine templates, but it fails the moment it encounters human customizations. Instead of shipping a brittle script, shadcn introduced two mechanisms: multi-base support in the CLI for initializing or reinstalling styled primitives, and a dedicated AI migration skill that reads both implementations, compares the diffs against pristine golden pairs, and carries your customizations forward progressively.

The Mental Model: Tailoring a Bespoke Suit

Think of your shadcn component directory like a collection of tailored suits rather than off-the-rack clothing. Radix provided the initial fabric and pattern. Over months of building your application, you hemmed the pants, adjusted the sleeves, and added custom pockets.

A traditional codemod acts like an automated cutting machine programmed strictly for the original factory pattern. If you run it over a tailored suit, it cuts straight through your custom seams and ruins the garment. To change the underlying fabric to Base UI without destroying the tailoring, you must measure your specific alterations, understand how the new material behaves, and apply the adjustments seam by seam.

In code, this difference is immediately visible at the boundary between primitive wrappers and consumer call sites. In Radix, polymorphism is driven by the asChild prop:

tsx
// src/components/ui/button.tsx (Radix)
const Comp = asChild ? Slot : "button"
return <Comp className={cn(buttonVariants({ variant, size }))} {...props} />

In Base UI, that entire mechanism is replaced by the render prop:

tsx
// src/components/ui/button.tsx (Base UI)
return (
  <ButtonPrimitive
    render={render}
    className={cn(buttonVariants({ variant, size }))}
    {...props}
  />
)

Because asChild expects a single React element passed as a child while render accepts an element or a render function directly on the prop, changing the primitive under the hood ripples out to every call site in your application that relied on the previous convention.

When To Use It

Reaching for Base UI or switching your project base makes sense in very specific situations.

If you are bootstrapping a new project or building a greenfield feature set where you want modern primitive features like built-in field validation states, native transition hooks, or unstyled positioning that separates the viewport from the popup box, Base UI is the right default.

It is also appropriate if you are maintaining an existing project and decide to execute a deliberate, progressive component migration using the official skill. In that scenario, you migrate foundational leaf primitives first, such as buttons and labels, and verify that your application typechecks and renders correctly after each step.

Before undertaking a primitive upgrade across your UI layer, make sure you actually need to own the underlying behavior. If you are weighing whether to adopt headless primitives like Radix and Base UI or keep hand-rolling interaction logic, read Headless UI Primitives: When to Own UI Behavior Today. It outlines a pragmatic framework for identifying invisible interaction risks—such as focus traps, keyboard navigation, and ARIA state—before committing engineering time to component architecture.

When NOT To Use It

You should avoid migrating to Base UI if your production application is already stable, thoroughly tested, and running on Radix primitives without issue.

Radix UI is not deprecated. It remains officially supported by shadcn, and its primitives have been battle-tested across millions of production interfaces. If you have an established app with dozens of complex dialogs, navigation menus, and form controls, migrating simply because the default changed adds risk with very little tangible benefit to your end users.

Equally important, never attempt to migrate by deleting your components/ui folder and running an unconditional CLI re-initialization. Doing so wipes out all custom variants, resets accessibility adjustments, and immediately introduces breaking type mismatches across every page in your repository.

Gotchas and Common Mistakes

These come from migrating a real Next.js app with shadcn apply --preset. Most were not caught by the TypeScript compiler.

A wrong aliases.utils creates bare "cn" imports

The CLI writes cn imports based on aliases.utils in components.json. In this repo the alias pointed at @/lib/utils, but cn actually lived in @/lib/helpers/utils. The CLI could not find it, so it generated from "cn" (the bare npm package) imports in 51 files, added a cn dependency, and created a new src/lib/utils.ts:

typescript
// src/lib/utils.ts (created by the CLI)
export { cn } from "cn"

It did not overwrite an existing file here, but nothing guarantees that if your utils.ts already holds real helpers. Set the alias to the true location before running apply:

json
"aliases": {
  "utils": "@/lib/helpers/utils"
}

If it already happened, rewrite the from "cn" imports to your real path, delete the stray file, and remove the cn package. Either way, read your git diff before you commit.

Switching the base goes through style

A top-level "base": "base" key made components.json invalid. What worked was setting style to base-nova, after which shadcn info reported base: base. Then shadcn apply --preset <code> rewrote the style again (to base-mira in our case).

json
"style": "base-nova"

A preset changes more than the base

Applying a preset is not only a Radix-to-Base-UI swap. In this repo the same commit also changed the icon library from lucide to phosphor, baseColor from neutral to zinc, react-day-picker from 9.14 to 10.0.2, and shadcn to 4.21, and it added @base-ui/react. Expect icon imports and calendar props to change along with the primitives, and review those diffs separately.

"use client" failures only show up in a real build

The regenerated breadcrumb.tsx uses useRender and shipped without a "use client" directive. Rendered from a server page, it crashed prerendering with createContext is not a function. The opposite mistake also bites: adding "use client" to button.tsx broke every buttonVariants() call made from a server component, because the export becomes a client reference.

tsx
// breadcrumb.tsx: wrappers built on useRender need it
"use client"

import { useRender } from "@base-ui/react/use-render"

The rule that worked: add the directive to useRender wrappers (badge, breadcrumb, button-group, item), and never to files whose exports, like buttonVariants, are called on the server. tsc passed in both broken states. The failures only appeared in a Vercel build.

Select needs items

Base UI's Select maps values to labels through an items prop. Without it, the trigger displays the raw value instead of the label. onValueChange also receives string | null, so your handlers need to deal with null.

tsx
<Select
  value={currentLocale}
  items={languages.map((l) => ({ label: l.code.toUpperCase(), value: l.code }))}
  onValueChange={(value) => value !== null && handleLanguageChange(value)}
>

Other runtime-only breaks

These compiled fine or failed only at runtime:

  • data-[state=open] selectors on collapsible and accordion triggers become data-[panel-open] (group-data-[panel-open]:rotate-180 in our chevrons).
  • Accordion type="single" collapsible is gone. Remove those props.
  • form.tsx was not regenerated, so it still imported Radix Slot and Label. FormControl needed a rewrite around React.cloneElement.
  • When render is not a native button (for example render={<Link href="/contact" />}), add nativeButton={false}. This does not apply to HoverCardTrigger.
tsx
<Button render={<Link href="/contact" />} nativeButton={false}>
  Contact
</Button>

Menus and overlays split into parts

Radix handles collisions, positioning, and content in a single Content component. Base UI splits this into Portal, Positioner, and Popup. If you swap wrappers without accounting for how your app passes alignment offsets or portal containers, dropdowns can compile but render detached from their triggers.

Converting asChild call sites

We converted 61 asChild call sites to render with a small script built on the TypeScript compiler API. It handled the single-child case (<Button asChild><Link /></Button>) mechanically. Only a conditional-child case in cart-sheet.tsx needed manual work. This fits the article's theme: scripts work when the shape is regular, and the irregular cases are yours to fix.

A Migration Checklist

  1. Create a branch and back up src/components/ui.
  2. Find your real customizations with git log -- src/components/ui. In our case only breadcrumb.tsx had local edits (accessibility commits), and reading the log was faster than diffing every component.
  3. Fix aliases.utils in components.json so it points at the file that exports cn.
  4. Set the base through style (for example base-nova).
  5. Run shadcn apply --preset <code> and review the full diff, including the icon library, base color, and dependency changes.
  6. Repair any bare "cn" imports, delete the stray utils.ts, and remove the cn package.
  7. Convert asChild to render, adding nativeButton={false} where the target is not a button.
  8. Fix Select items, Accordion props, ToggleGroup props, and data-state selectors.
  9. Add "use client" to useRender wrappers only.
  10. Run npx tsc --noEmit, then deploy a preview. Typechecking alone missed the use client failures.

Conclusion

The transition from Radix UI to Base UI in shadcn represents a fundamental shift in how modern headless components are structured, but it does not mean your existing codebase needs an emergency rewrite. You own your code, which makes automated codemods impractical. Reach for Base UI when building new projects or when a specific primitive feature solves an active problem, and leave your stable Radix foundations alone if your application is already shipping reliably.

If you do migrate, the cost depends on how much you have customized. A small, mostly stock components/ui (ours had one customized file) took about an afternoon. A heavily customized one is where the real cost sits, because every local edit has to be found and carried forward by hand.

If you have questions or ran into a different gotcha, drop a comment below. And if you found this useful, subscribe for more.

Thanks, Matija

Comments