BizzPocket

Migrating a 2.x theme: bizzpocket migrate

On this page

Themes written for SDK 2 use defineBlock, zod schemas, a hand-written manifest.ts and an index.ts that lists every block. bizzpocket migrate rewrites such a theme into the SDK 3 layout: block() and f.* fields, one folder per block, theme.config.ts and layouts/*.json.

Old themes keep working without migrating. Migrate when you want the shorter code and the faster build.

bash
bunx bizzpocket migrate path/to/theme --dry-run     # look first: writes nothing
bunx bizzpocket migrate path/to/theme               # do it
Flag What it does
[themeDir] The theme folder. Default ..
--dry-run Plans everything in memory and prints the report. Writes nothing.
--json Prints the full report as JSON instead of the short summary.

Always run --dry-run first, and commit your work before the real run so you can see exactly what changed with git diff.


What it expects

  • A src/manifest.ts (or manifest.ts) that exports the 2.x theme manifest.
  • Blocks written with defineBlock.
  • A theme that is not yet migrated. If theme.config.ts already exists, migrate stops.

What it does

In the 2.x theme In the migrated theme
manifest.ts (id, name, tokens, overridable, styles, verticals, starterContent, homeContent, cdn, iconSet, image) theme.config.ts with the same values, written as theme({ ... }) (theme-config.md)
defaultLayouts in the manifest layouts/<page>.json (@global becomes global.json), with themeVersion removed (the build adds it)
defineBlock({ contract, variant, component }) block({ ... }) in blocks/<name>/<name>.tsx
the zod propsSchema fields: { ... } using f.text, f.number, f.select, f.list...
editor hints (label, help, tier, group, controls) carried onto the matching f.* option
defaultProps the default of each field
component(...) render(...)
a loader that only calls the standard storefront operations data(), a static template (data.md)
any other loader load(), with a small generated helper (legacy-loader-context.ts) so the old code keeps working
lcpBlock, aboveFold, category, role, description lcp, aboveFold, category, role, description
the registry files: index.ts, the manifest's variant tables deleted
imports of moved files rewritten to the new paths
package.json scripts.build bizzpocket build --target both
tsconfig.json include extended with blocks, layouts, lib and theme.config.ts

A block folder that held a single block moves whole: its helper files and its client files go with it.

Block ids are preserved

A store's saved layouts refer to blocks by their old ids. Migrate keeps them. Each migrated block carries the old ids explicitly:

tsx
export const GridStandardToyshop = block({
  type: 'product-grid',                    // the old block type
  variant: 'grid-standard-toyshop',        // the old variant id
  name: 'ToyShop standard product grid',
  ...

In a new block you never write type or variant: the folder name is the block's name. They exist only so migrated blocks keep matching what is already saved. Do not remove them from a migrated block that stores already use.

Migrate also checks that every variant in the old manifest was converted. If one would be dropped it stops with an error and writes nothing.

Before and after

A 2.x block:

tsx
export const GridStandardToyshopPropsSchema = z.object({
  title: TrimmedStringSchema.optional(),
  collection: TrimmedStringSchema.optional(),
  limit: z.number().min(1).max(48).default(12),
  grid: z.enum(['2', '3', '4']).default('4'),
});
const defaultProps = GridStandardToyshopPropsSchema.parse({ limit: 12 });

export const GridStandardToyshop = defineBlock<Props, LoaderData>({
  contract: {
    type: 'product-grid',
    editor: { category: 'products', fields: { collection: { control: 'collection' } } },
    propsSchema: GridStandardToyshopPropsSchema,
    defaultProps,
    cacheHint: { sMaxAge: 60, tags: ['products'], timeoutMs: 2500 },
    perfBudget: { serverRenderMs: 50, clientHydrateKb: 0 },
    loaderSchema: GridStandardToyshopLoaderSchema,
    async loader(ctx) { /* one productGridProducts call */ },
  },
  variant: { id: 'grid-standard-toyshop', displayName: 'ToyShop standard product grid' },
  component({ blockProps, data }) { /* JSX */ },
});

becomes:

tsx
export const GridStandardToyshopFields = {
  title: f.text({ optional: true }),
  collection: f.collection({ help: 'Leave empty for the whole catalogue.' }),
  limit: f.number({ min: 1, max: 48, default: 12 }),
  grid: f.select(['2', '3', '4'], { default: '4' }),
};

export const GridStandardToyshop = block({
  type: 'product-grid',
  variant: 'grid-standard-toyshop',
  name: 'ToyShop standard product grid',
  category: 'products',
  fields: GridStandardToyshopFields,
  data: (props) => ({ liveGrid: products({ collection: props.collection, first: props.limit }) }),
  render(props) { /* the same JSX */ },
});

The JSX, the comments and the helpers keep their text, byte for byte.


The report

migrated 3 blocks: 1 converted, 1 partial, 1 kept
  files: 7 -> 6
  ceremonyLines: 120 -> 1
  registrations: 24 -> 0
  blocks/not-found/not-found.tsx (partial):
    - contract.i18nKeys dropped (was ['notFound.heading', 'notFound.body', 'notFound.cta'])

Each block ends in one of three states:

Status Meaning
converted Fully converted. Nothing for you to do.
partial Converted, but something could not be carried over. It is listed as a TODO.
kept Converted, and its loader was kept as load() because it could not become data(). Works as before, but it is runtime and never cached.

The three counts show how much boilerplate went away: source files, ceremonyLines (registry lines and per-block contract lines) and registrations (mentions of a block in registry files).

--json prints the same thing with every TODO, plus notes:

json
{ "blocks": [{ "file": "blocks/not-found/not-found.tsx", "type": "not-found", "variantId": "not-found-toyshop",
               "status": "partial", "todos": ["contract.i18nKeys dropped (was [...])"] }],
  "before": { "files": 7, "ceremonyLines": 120, "registrations": 24 },
  "after":  { "files": 6, "ceremonyLines": 1, "registrations": 0 },
  "notes": [] }

notes are theme-level remarks: a file that was skipped or left alone (for example ... 2 defineBlock calls in one module; left in place), or an import of a file migrate deleted.


What the TODOs mean

Every TODO is also written into the code as a comment: // TODO(migrate): <what>, above the block or next to the field. Find them all with:

bash
grep -rn "TODO(migrate)" blocks lib components

A TODO means "this part of the old block has no direct SDK 3 counterpart, so migrate did not guess". Read it, decide, delete the comment. Each TODO has a severity (blocking, behaviour or info) and the highest one sets the block's status (broken, review, converted). Every kind migrate knows is listed with its severity; a TODO it does not recognise counts as blocking, so a block is never reported better than it is. Migrate checks each block's server code the way the build does (relative imports, package.json #imports, tsconfig paths, .mjs/.cjs/.cts/.mts files, re-export barrels): client-only React there, or a file that does not parse, is blocking. When it lifts a <Client> subtree into an island, it passes only the members the island reads (props={{ data: { items: props.data?.items } }}), not the whole props. Common ones:

TODO says What to do
validation dropped: ... The zod rule had no f.* option. Check the value in render, or accept the looser rule.
default factory dropped / list default dropped A computed or list default. Set the default by hand in the field, or handle empty in render.
was nullable: null becomes undefined Fields now give undefined for "nothing". Check === null in render.
integer constraint (.int()) dropped, min length N dropped Same: add a check in render if you need it.
contract.<key> dropped (i18nKeys, seo, timeoutMs, onSchemaError...) SDK 3 has no such setting. Nothing replaces it, so make sure you do not depend on it.
skeleton dropped (block() has no Suspense skeleton) Blocks now render with the page or stream in. Use lcp / aboveFold for the important ones.
component was async; render must be synchronous Move the await into data() or load().
component parameter ... has no render counterpart / context.<x> has no RenderContext counterpart render(props, ctx) has ctx.store, ctx.route and ctx.blockId. Rework the reference.
<Client> island renders inline under block() Move the interactive subtree into a 'use client' file (interactivity.md).
server render reaches <file>, which calls useState() ... That file (or a barrel it imports) uses client-only React in server code: CLIENT_HOOK_IN_SERVER. Give it 'use client' or move the hook into a client file.
server render reaches <file>, which does not parse ... Fix the syntax (PARSE_FAILED).
ctx.<x> is not provided by load() A loader read a legacy context member that is no longer there (it is undefined). Pass it a different way.
loader data type unknown render's data prop is untyped. Add a type.
zod still used by ... Some code still imports zod. That is fine on the server. It is not allowed in 'use client' files.
propsSchema not converted: ... The schema was too dynamic to read. Write the fields by hand.

After you migrate

  1. Fix the package.json exports. The build needs "exports": { "./styles": "./dist/styles.css" }. Migrate does not change exports, and an old theme often points ./styles at a file in src/. Without the fix you get STYLES_EXPORT_MISSING.
  2. Wire up your CSS. The build only includes a CSS file that a block imports, plus styles/global.css. Move the old global stylesheet to styles/global.css, or import it from the blocks that use it.
  3. Check for layouts/global.json. If your old manifest had no global layout and layouts/home.json has no header and footer, the build stops with NO_GLOBAL_LAYOUT.
  4. Resolve the TODOs (above).
  5. Build and compare:
bash
bunx bizzpocket build

Read the data column (data.md). Blocks that had a standard loader should be static; kept blocks show runtime. 6. Port tests by hand. Tests that reach into a block's internals (for example a zod schema) are not rewritten.

Next: troubleshooting.md.