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.
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(ormanifest.ts) that exports the 2.x theme manifest. - Blocks written with
defineBlock. - A theme that is not yet migrated. If
theme.config.tsalready 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:
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:
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:
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:
{ "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:
grep -rn "TODO(migrate)" blocks lib componentsA 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
- Fix the
package.jsonexports. The build needs"exports": { "./styles": "./dist/styles.css" }. Migrate does not changeexports, and an old theme often points./stylesat a file insrc/. Without the fix you getSTYLES_EXPORT_MISSING. - Wire up your CSS. The build only includes a CSS file that a block imports, plus
styles/global.css. Move the old global stylesheet tostyles/global.css, orimportit from the blocks that use it. - Check for
layouts/global.json. If your old manifest had no global layout andlayouts/home.jsonhas no header and footer, the build stops withNO_GLOBAL_LAYOUT. - Resolve the TODOs (above).
- Build and compare:
bunx bizzpocket buildRead 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.