Reference

Gotchas

Things that cost real time to work out.

Things that cost real time to figure out.

Theme structure

Section filenames are global. Flattening means two files with the same name in different folders collide. The build warns rather than silently overwriting.

Section groups only accept header, footer or aside as type. Anything else fails to upload with no useful error, and the section silently renders nothing.

Removing a preset removes its default blocks. If a section's blocks are supposed to exist by default, they belong in the group or template JSON.

Schemas

{% schema %} is pure JSON. No Liquid tags inside, and unknown keys are rejected — even a "comment" field.

Range settings need at least 3 selectable values. (max - min) / step + 1 must be ≥ 3.

1{ "type": "range", "id": "span", "min": 1, "max": 2, "step": 1 } // fails to upload
2{ "type": "range", "id": "span", "min": 1, "max": 3, "step": 1 } // fine

visible_if doesn't work on resource pickerscollection, product, page, blog, article, metaobject and their _list variants. Every other type is fine, and referencing a picker from another setting's visible_if works.

Shopify has no file-upload setting type. image_picker and video filter by media type. For fonts or anything else, a text field is your only option.

React

The theme editor replaces section markup on every edit. React remounts and loses state. Persist anything that should survive with useSectionState.

useStore selectors must be Object.is-stable or you get an infinite render loop.

1useStore(cartStore, (s) => s.cart.item_count) // fine — a primitive
2useStore(cartStore, (s) => s.cart) // fine — the same reference
3useStore(cartStore, (s) => s.cart.items ?? []) // loops — a new [] every call

Depend on the whole useSelectedBlock() object, not .id. Re-selecting the same block produces the same id, and only seq tells you it happened again.

1const selection = useSelectedBlock()
2useEffect(() => {}, [selection.id]) // dead after the first click
3useEffect(() => {}, [selection]) // fires every time
4
5useRevealOnSelect(blocks, (i) => setActive(i)) // better — handles it for you

Import Lucide icons by name, never the namespace.

1import { ShoppingBag } from 'lucide-react' // yes
2import * as Icons from 'lucide-react' // no — pulls in every icon

The API

Deploying a Shopify app is not installing it. The App Proxy only exists on stores where the app is installed.

prefix and subpath are fixed at install time. Changing them later needs an uninstall and reinstall, not just a redeploy.

Adding an operation doesn't reach production until you rebuild. Operations are compiled into the deploy folder.

npm run api:build
cd deploy/netlify && npx netlify deploy --prod

Money maths goes on the integer, never the formatted string. Re-formatting cents in JavaScript breaks currencies with non-decimal subunits.

1const total = lines.reduce((sum, line) => sum + line.final_line_price, 0) // integer
2<span>{cart.total_price_formatted}</span> // render the string