Guides · February 22, 2023
Commerce Blocks: Composing Whole Storefront Sections
Commerce blocks are section-sized, composable UI units — hero, product grid, PDP gallery, cart drawer — built on primitives like shadcn/ui so a storefront ships as an assembly of tested parts instead of one-off pages. Here is how to design, build, and compose them well.
By Polo Themes
A commerce block is a section-sized, self-contained piece of storefront UI — a hero, a product grid, a PDP gallery, a cart drawer — that owns its own data-fetching contract, its own layout, and a small, deliberate prop API, so it can be dropped into any page and composed with other blocks. The pattern matters because it moves storefront work from "rebuild the page" to "assemble from tested parts": you compose blocks built on a primitive layer like shadcn/ui, wire each one to your commerce backend through a thin adapter, and get consistent, accessible sections everywhere they appear. This guide walks through the three-layer architecture, the prop and data contracts that make blocks reusable instead of brittle, and a worked example of building a product-grid block end to end.
Why "Blocks" Instead of "Pages"
Most storefronts start as pages: a homepage component, a category page component, a PDP component, each written top-to-bottom with its own markup, its own data fetching, its own responsive rules. That works fine for the first storefront. It breaks down the moment you need a second brand, a seasonal landing page, or an A/B test that swaps a hero for a different layout — because none of the pieces were built to be moved. Commerce blocks solve this by inverting the unit of reuse: instead of reusing whole pages, you reuse the sections pages are made of. A homepage becomes an ordered list of blocks (hero, featured collection grid, testimonial strip, newsletter signup); a PDP becomes another ordered list (gallery, buy box, description accordion, related products). The page component's only job is to decide which blocks appear and in what order — all the real UI logic lives inside the blocks themselves.
This is not a new idea — Shopify's section-based Online Store 2.0 architecture popularized exactly this model for theme editors, letting merchants reorder and configure sections without touching code. The shift happening now is bringing that same block discipline to headless, Next.js-based storefronts, using shadcn/ui-style component patterns as the primitive layer instead of a proprietary theme engine. The result is a storefront that a developer can compose visually in code, block by block, with the same confidence a merchant has rearranging sections in a theme editor — except every block is a real, typed, testable component.
The Three-Layer Architecture
Composable storefronts hold together when you keep three distinct layers separate and never let them blur into each other.
Layer 1: Primitives
Primitives are the unstyled or lightly styled building blocks — button, dialog, sheet, accordion, tabs, carousel, badge. This is exactly the layer shadcn/ui occupies: rather than a component library you install as a dependency, it's a set of copyable, ownable component source files built on Radix primitives, styled with Tailwind, and fully yours to modify. That ownership model matters for commerce specifically, because a cart drawer or a variant selector inevitably needs a small deviation from the stock component, and you don't want to fork a node_modules package to get it. Primitives know nothing about products, carts, or commerce data — a Sheet component has no idea it will later be used to render a cart drawer; it just knows how to be a sheet.
Layer 2: Commerce Blocks
Blocks are where commerce semantics enter. A ProductGrid block composes primitives (card, badge, skeleton, pagination) with commerce-specific logic: it knows what a product is, how to render a price with a compare-at strikethrough, how to show a sold-out state, and how to paginate or infinite-scroll. A CartDrawer block composes a Sheet primitive with line-item rows, quantity steppers, and a checkout CTA. Blocks are the unit designers and merchandisers actually think in — "the hero," "the product grid," "the reviews section" — and they are the unit this guide is mainly about.
Layer 3: Page Composition
The page layer is deliberately thin. A category page is a server component that fetches a small amount of page-level data (which collection, which blocks, in what order) and then renders blocks in sequence, passing each one its slice of data. If your page components are doing heavy layout work themselves, some of that logic belongs one layer down, inside a block.
Designing a Block's Contract
The difference between a reusable block and a one-off component is almost entirely in its contract — the props it accepts and the data shape it expects. Get this wrong and every new placement needs a one-off variant; get it right and the same block works on the homepage, a category page, and a search-results page without modification.
Prefer configuration props over layout props
A well-designed block accepts props that describe what to show and how it should behave, not pixel-level layout instructions.
- Good: columns={4}, showQuickAdd, variant="compact" — these describe behavior and let the block's internal CSS handle the actual layout.
- Fragile: passing raw Tailwind class strings or inline style objects into a block from the page layer — this leaks layout decisions upward and makes the block impossible to reason about in isolation.
- Good: a small, closed set of named variants ("default" | "compact" | "carousel") rather than an open-ended set of boolean flags that multiply into untested combinations.
Decouple the block from any one data source
A ProductGrid block should accept an already-normalized array of product objects — id, title, price, image, availability — not a raw response shape from a specific commerce API. That normalization happens once, in an adapter layer between your commerce backend (Shopify, Medusa, a custom API) and your blocks. This is the single highest-leverage decision in a headless storefront: it means switching commerce backends, or supporting two backends at once, touches the adapter layer only, and every block keeps working unchanged. Skip this step and your blocks end up littered with backend-specific field names, and a migration turns into a rewrite of every section instead of one file.
Design for the empty, loading, and error states first
Commerce data is asynchronous and frequently incomplete — a product with no images, a collection with zero results after filtering, a cart that failed to sync. Blocks that are designed against the happy path alone tend to look broken in production the first time real data misbehaves. Build the empty state, the skeleton/loading state, and a graceful error fallback as part of the block's initial spec, not as an afterthought once QA finds the gap.
Worked Example: A ProductGrid Block
Walking through one block end to end makes the contract concrete. A ProductGrid block for a Next.js App Router storefront typically breaks into three files: a server component that fetches and normalizes data, a client component that handles interactive filtering/sorting, and the presentational grid itself built from shadcn/ui primitives.
- Server shell — an async server component fetches the collection's products through your commerce adapter, normalizes them into a plain product array, and streams the result to the client boundary. This is where Next.js's server-first data fetching pays off: no client-side waterfall, no loading spinner on first paint.
- Client interactivity boundary — a "use client" component receives the normalized products and owns any client-only state: active filters, sort order, an "add to cart" mutation that needs to run in the browser. Keep this boundary as small as possible; the more of the block that can stay server-rendered, the less JavaScript ships to the browser.
- Presentational grid — a plain component built from a Card primitive (image, title, price, badge for sale/sold-out) repeated in a CSS grid, with Skeleton primitives standing in during the loading state and an explicit empty-state message when a filter returns zero products.
Notice what's absent from that list: nothing about which commerce backend the products came from, and nothing about which page the grid is rendered on. That's the point. The same three files render the homepage's "New Arrivals" grid, a category page's filtered grid, and a search-results grid, differing only in what array of normalized products gets passed in.
Where Design Systems Fit In
Commerce blocks are only as good as the design system feeding them. A block built without a real design source tends to drift — spacing gets approximated, states get invented ad hoc, and every developer's grid ends up subtly different. This is one reason well-structured Figma libraries matter even for teams building fully custom, headless storefronts: a component-accurate design source (states, spacing tokens, responsive breakpoints already decided) gives the engineering team something precise to translate into a block's variants, rather than reverse-engineering intent from a flat mockup. If you're assembling a commerce-focused component library from scratch, it's worth browsing existing **commerce-focused Figma UI kits** for reference on how mature product teams structure exactly these sections — grids, PDP layouts, cart drawers — before committing to your own block boundaries.
This also connects to a broader shift worth naming plainly: AI-assisted, design-to-code workflows are getting good enough that a well-structured Figma file — one with real components, variants, and auto-layout, not flat artwork — is becoming a legitimate input to code generation, not just a picture for a developer to eyeball. Blocks with a clean, closed prop contract are exactly the target shape that kind of workflow needs; a component with ad hoc boolean flags and inline styles is much harder for either a human or a model to translate faithfully. Designing blocks well today is also designing for a near-future where more of that translation is automated.
Testing and Documenting Blocks in Isolation
Because a block owns a closed prop contract, it can — and should — be developed and tested without any real page around it. Render each block against a handful of representative fixtures: a normal product set, an empty result set, a single item, a product missing an image, a long title that needs to truncate gracefully. Snapshot or visual-regression testing at the block level catches far more real breakage than end-to-end tests alone, because it exercises every state combination cheaply and in isolation, rather than only whatever states happen to occur on a live page during a test run. Treat each block's fixture set as living documentation: a new engineer should be able to open the block's test file and understand its entire behavioral surface without reading a single page component.
Common Mistakes When Adopting the Block Pattern
- Letting a block reach outside its contract — a block that imports a page-specific hook or reads from global state defeats the entire point of the pattern; it can no longer be dropped somewhere else unmodified.
- Skipping the adapter layer — wiring a block directly to a specific commerce API's response shape works fine until you need a second backend, a migration, or a mock data source for testing, at which point every block needs surgery instead of one adapter.
- Over-parameterizing — a block with fifteen boolean props to handle every edge case anyone has ever asked for is functionally no longer reusable; it's just complicated. Prefer a small number of named variants and let genuinely different needs become genuinely different blocks.
- Under-investing in the empty and error states — these are the states real users hit constantly (an out-of-stock filter, a slow network, a cart sync failure) and the states teams design last, if at all.
Frequently Asked Questions
Do commerce blocks require shadcn/ui specifically?
No — the pattern is about layering (primitives, blocks, page composition), not any one library. shadcn/ui fits well because its copy-into-your-repo model means you fully own and can adapt the primitive layer, which commerce UI frequently needs to do (custom variant pickers, non-standard cart drawer behavior). Any primitive layer you fully control — a Radix-based system you've built yourself, for instance — works the same way.
How is a "block" different from a generic reusable component?
Scope. A generic component (a Button, a Badge) is a primitive with no commerce knowledge. A block is section-sized and commerce-aware — it knows what a product or a cart line item is — and is meant to be placed directly into a page as a complete piece of UI, not composed further into something smaller.
Does this pattern work with a headless backend like Medusa, or only Shopify?
It works with any backend, headless or not — that's the entire value of the adapter layer described above. The block only ever sees a normalized product or cart shape; whether that data originated from Shopify's Storefront API, a self-hosted Medusa instance, or another headless commerce platform is invisible to the block itself.
How many blocks does a typical storefront need?
Most storefronts converge on a fairly small set — usually somewhere between fifteen and thirty blocks covering hero variants, product grids, PDP sections, cart and checkout UI, and a handful of content/marketing sections. The count matters less than the discipline of the contract; a storefront with ten well-designed blocks composes more flexibly than one with fifty loosely specified ones.