Catalyst migration
If USA Clean replatforms from Stencil to BigCommerce's Catalyst (Next.js + React + Tailwind v4), Caster comes along for the ride. The token contract stays identical — only the consumption layer changes. This guide covers the future-state setup so the eventual replatform is a config swap, not a redesign.
1 · Set up Catalyst with Caster tokens
Catalyst is a Next.js 14+ app using Tailwind v4. Tailwind v4 reads its theme from CSS variables via the @theme directive — which is exactly what tokens.tailwind.css ships.
Vendor the tokens
your-catalyst-app/
├── app/
│ ├── globals.css ← Tailwind entry; we'll edit this
│ └── ...
├── public/
├── styles/
│ └── caster/
│ ├── tokens.tailwind.css ← from design-system/tokens/
│ └── components.css ← from design-system/components/components.css
│ (only if you want Caster's component classes too)
└── tailwind.config.ts ← may not exist in v4 — config moves to CSSWire it into globals.css
/* app/globals.css */
@import "tailwindcss";
/* Caster tokens — defines all theme variables via @theme block */
@import "../styles/caster/tokens.tailwind.css";
/* Optional: Caster component classes alongside Tailwind utilities */
@import "../styles/caster/components.css?v=0.16.3";
/* Your app-specific styles */
@layer components {
/* ... */
}That's it. Tailwind utilities now resolve through Caster tokens. bg-brand-default uses the brand blue, p-4 uses Caster's spacing.4 (16px), text-text-default uses the body text color.
2 · Using utilities (the React way)
In Catalyst, components are React components. Tailwind utilities replace most CSS:
Caster button as a React component
// components/Button.tsx
type ButtonVariant = 'primary' | 'brand' | 'outline' | 'ghost' | 'danger';
type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
const variants: Record<ButtonVariant, string> = {
primary: 'bg-accent-default text-text-default hover:bg-accent-hover',
brand: 'bg-brand-default text-text-inverted hover:bg-brand-hover',
outline: 'bg-transparent text-brand-default border-2 border-brand-default hover:bg-brand-default/6',
ghost: 'bg-transparent text-text-subtle border border-border-default hover:bg-surface-subtle',
danger: 'bg-feedback-error-default text-text-inverted hover:bg-feedback-error-hover',
};
const sizes: Record<ButtonSize, string> = {
xs: 'h-7 px-3 text-2xs',
sm: 'h-9 px-4 text-xs',
md: 'h-11 px-5 text-sm',
lg: 'h-13 px-6 text-md',
xl: 'h-15 px-8 text-md',
};
export function Button({
variant = 'brand',
size = 'md',
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
size?: ButtonSize;
}) {
return (
<button
className={`inline-flex items-center justify-center gap-2 rounded font-semibold whitespace-nowrap transition-colors ${variants[variant]} ${sizes[size]}`}
{...props}
>
{children}
</button>
);
}Notice that all the values resolve through Caster: bg-brand-default → var(--caster-color-brand-default) → #0021a6. Re-themes still happen at the token layer.
3 · Two strategies for component coverage
Strategy A — Use components.css directly
Import Caster's components.css alongside Tailwind utilities. Use className="btn btn-primary" exactly as in vanilla.
- Pro: zero rewrite. Identical markup as Stencil.
- Pro: caster components stay maintained in one place.
- Con: doesn't feel native to a React/Tailwind codebase.
- Con: Tailwind's CSS isolation strategies (e.g., scoped
@layer) get more complex.
Strategy B — Wrap Caster classes in React components (recommended)
Build idiomatic React components like the <Button> example above. They consume Caster tokens through Tailwind utilities. Reuse the same prop API across components.
- Pro: idiomatic React + TypeScript. Type-safe variants.
- Pro: tree-shakeable; unused styles never ship.
- Con: more upfront work — every component needs a wrapper.
- Con: two source-of-truth: Caster's components.css for Stencil, React components for Catalyst.
4 · Server vs client components
Catalyst uses Next.js App Router. Caster components are pure presentational — they have no state, no event handlers — so they default to server components. No 'use client' needed.
// components/ProductCard.tsx — server component (default in App Router)
import { Button } from './Button';
import { StockBadge } from './StockBadge';
export function ProductCard({ product }: { product: Product }) {
return (
<div className="bg-surface-default border border-border-default rounded-md overflow-hidden flex flex-col">
<a href={`/product/${product.slug}`} className="aspect-square bg-surface-subtle">
<img src={product.image} alt={product.name} className="w-full h-full object-contain p-4" />
</a>
<div className="p-4 flex-1 flex flex-col gap-2">
<div className="text-2xs text-text-muted">SKU · {product.sku}</div>
<a href={`/product/${product.slug}`} className="text-xs font-semibold text-text-default hover:text-brand-default">
{product.name}
</a>
<div className="mt-auto pt-2 border-t border-border-subtle flex justify-between">
<div className="text-md font-bold">${product.price}</div>
<StockBadge state={product.stockState} />
</div>
</div>
<div className="px-4 pb-4">
<Button variant="brand" size="sm" className="w-full">Add to cart</Button>
</div>
</div>
);
}For interactive components (Add-to-cart actions, qty steppers, search), wrap the interactive part in a child client component. The card itself stays server-rendered.
5 · Migration phases (when the time comes)
- Phase 1 — Tokens contract. Set up Catalyst app, import
tokens.tailwind.css. All Tailwind utilities now use Caster values. No components migrated yet. - Phase 2 — High-traffic React components. Build
<Button>,<ProductCard>,<Pill>,<StockBadge>with Caster utilities. Visual parity with Stencil site. - Phase 3 — Page templates. Build PLP, PDP, header, footer using the React components. Soft-launch behind a feature flag.
- Phase 4 — Long-tail components. Forms, account pages, checkout. Each gets a React wrapper.
- Phase 5 — Decommission Stencil theme. Once Catalyst hits feature parity and metrics hold, redirect
shop.usaclean.comto the Catalyst app.
What survives the migration
Token names and semantic structure don't change. If a component on Stencil uses --caster-color-brand-default, the Catalyst version uses bg-brand-default (which resolves to the same value). When the brand re-themes, both sides update simultaneously.
| Asset | Stencil consumption | Catalyst consumption |
|---|---|---|
| Brand color | $caster-color-brand-default | bg-brand-default |
| Text color | $caster-color-text-default | text-text-default |
| Spacing | $caster-spacing-4 | p-4 |
| Component button | .btn .btn-primary | <Button variant="primary" /> |
| Figma variables | Tokens Studio | Tokens Studio (same JSON) |
Caveats
Tailwind v4 specifically — v3 is different
v4's @theme directive is what makes drop-in token consumption work. For Tailwind v3 (older Catalyst forks or other React projects), import tokens.tailwind.js and spread it into theme.extend in tailwind.config.js. Same tokens, different config mechanism.
Class name collisions with Tailwind defaults
If both tokens.tailwind.css and Tailwind's defaults define a token (e.g. --color-blue-500), Caster's @theme wins because of import order. Caster's namespace prevents most collisions but check token names if behavior is unexpected.
React Server Components and CSS-in-JS
Caster ships pure CSS — no CSS-in-JS dependency. This works fine with React Server Components (CSS-in-JS libraries like styled-components don't). Win.