FlexPlat logoFlx Modernize

Writing a Storefront theme in TypeScript

Write a WooCommerce shop's pages in TypeScript. WordPress and WooCommerce keep running: the products, cart, orders and admin stay exactly where they are, and you take over how the pages look, one section at a time.

The Storefront renderer package has everything here: the renderer, a live preview, a starter theme and a checker. The demo at storefront.flexplat.com runs the modern-cards theme from the package.

Quick start

You need Node 22. You don't need WordPress. In the unzipped package:

cd renderer
npm install
npm run preview

Open http://localhost:4000/. That is the starter theme on real WooCommerce page data, and it restarts when you save. Everything it needs is in the kit, apart from Google Fonts and WordPress's emoji images. To use another port, set PORT, for example PORT=4100 npm run preview.

Page Preview URL
Home /
Shop /shop/
Product /product/hoodie/
Cart, Checkout, My account /cart/, /checkout/, /my-account/

Your first change

  1. Open renderer/src/theme/starter.ts.
  2. In productCard, change the markup, for example move the price above the name.
  3. Save and reload /shop/.

Your own theme

  1. Copy starter.ts to src/theme/my-theme.ts, and change name and the export.
  2. Register it in src/theme/index.ts.
  3. Run it:
FLX_RENDER_THEME=my-theme npm run preview
npm run check-theme my-theme

src/theme/modern-cards.ts is a fuller example: computed discount badges, a new product layout, header and footer.

What you can write

A theme is an object. Every function is optional: leave one out and that part stays as WooCommerce prints it. A function takes plain data and returns HTML as a string, so you can use any library that produces HTML.

Function Replaces Gets
productCard(card) What is inside each product's <li>, wherever a product list appears ProductCard in storefront/types.ts: name, URL, prices, stock, rating, image, button
productPage(parts) What is inside the single product's <div> The product's data, plus galleryHtml, purchaseHtml and afterHtml
header(parts) The site header, on every page Branding, menus, search, cart
footer(parts) The site footer, on every page Widgets, credit line, phone footer bar
css Nothing: it is added after the page's own CSS

Helpers in storefront/sections.ts:

  • addToCartLink(card, { className, text }): the add-to-cart button, with what WooCommerce's scripts need.
  • productImage(card, className): the product image with its responsive sizes.

The data you get

card in productCard, and product in productPage, share these fields:

Field What it is
id, name, type, sku type is simple, variable, grouped or external
price_html The price as WooCommerce prints it, including a sale's crossed-out price or a variable product's range
regular_price, sale_price Plain numbers as text, such as "20" and "18". Empty for a variable product, whose prices vary
on_sale, in_stock True or false
rating, review_count rating is 0 when there are no ratings, or the store turned them off

card also has url, image and add_to_cart. product also has short_description_html and categories (each with a name and url).

The full description isn't a separate field. It is in the Description tab, inside afterHtml. short_description_html is the summary you can place yourself, and it can be empty.

parts in header has brandingHtml, searchHtml, primaryMenuHtml, handheldMenuHtml, secondaryNavigationHtml, cartHtml and style. In footer it has widgetsHtml, creditHtml and handheldHtml. You can print them in any order, and arrange them with css.

Examples

A sale label, with the price above the name. starter.ts has the imports these need: escWpText and escWpAttr from ../lib/escape, safeUrl from ../storefront/menu, and addToCartLink and productImage from ../storefront/sections.

function productCard(card: ProductCard): string {
  const regular = Number(card.regular_price);
  const sale = Number(card.sale_price);
  const off = regular > 0 && sale > 0 && sale < regular ? Math.round(((regular - sale) / regular) * 100) : null;
  const url = escWpAttr(safeUrl(card.url));
  return `<div class="my-card">
<a href="${url}">${productImage(card)}</a>
${off !== null ? `<span class="my-card__sale">Only ${off}% off</span>` : ''}
<div class="my-card__price">${card.price_html}</div>
<h2><a href="${url}">${escWpText(card.name)}</a></h2>
${addToCartLink(card)}
</div>`;
}

The cart on the left of the menu row. Print cartHtml first, and lay out the row in css:

// In header():
// <div class="storefront-primary-navigation"><div class="col-full my-row">
// ${parts.cartHtml}
// <nav id="site-navigation" ...>...</nav>
// </div></div>

const css = `
.my-row { display: flex; align-items: center; gap: 24px; }
`;

Storefront's own CSS still loads, and it floats and sizes many of these parts. If your layout doesn't take effect, a more specific selector usually fixes it: src/theme/modern-cards.ts has examples.

Rules for your HTML

  • Text from WordPress goes through escWpText or escWpAttr, and URLs through safeUrl.
  • Fields ending in Html or _html are already HTML: print them as they are.
  • WooCommerce's scripts still run the shop, and they look for certain markup:
    • Use addToCartLink for card buttons.
    • Print galleryHtml, purchaseHtml and afterHtml whole.
    • Keep the classes summary entry-summary on the column with the form.
    • Keep product_meta with an element of class sku inside it.
    • Keep id="site-navigation" and the button with class menu-toggle in the header.

npm run check-theme checks that those are all there. It can't check your escaping.

Cart, Checkout and My account

These pages hold personal data and take passwords and payments, so they work differently. WooCommerce's own forms stay as they are, on your server. Your header and footer go around them, drawn in advance with no visitor data.

Because that markup sits next to password and payment fields, the plugin only accepts it if everything in it is on an allowlist. So in header and footer, and in css, on every page:

  • No forms or scripts. Print searchHtml and cartHtml in header, and handheldHtml in footer, whole and exactly once: WordPress fills those in. They can't go inside a, button, p, span, a heading or an inline tag such as strong. Inside div, nav, ul and li is fine.
  • Plain tags only: div, span, p, br, header, footer, nav, section, aside, ul, ol, li, h1–h6, strong, em, small, b, i, a, img, and button with type="button" or no type. So no svg, table or label. Open and close tags must match exactly.
  • Attributes: class, id, role, tabindex, title, aria-*, and each tag's usual ones (href, rel, target="_blank", src, srcset, sizes, alt, width, height, loading, decoding). No data-*. style only on header, and only exactly parts.style.
  • Ids: page, masthead, content, colophon, site-navigation, site-navigation-menu-toggle, or starting flx- or menu-, or a word and a number, such as a widget's search-2.
  • URLs: on the site, with no query string, or a menu link WordPress sent. So no mailto: or tel: links, and no images on a CDN.
  • CSS can't load anything: no url(), @import, backslashes or comments, no at-rule except @media, and none of the words expression, binding or behavior anywhere, even in a class name.

If anything breaks these rules, the plugin refuses the whole shell and those pages stay native, with WooCommerce's normal header and footer. The site keeps working, but your design stops at the cart. npm run check-theme tells you why, in words, and the preview shows the reason in a red banner at the top of those pages. No banner means the theme passes. These rules don't apply to the markup of catalogue pages, so a form in your footer can look fine on /shop/ and still be refused here.

Logged-in customers always get native pages.

Check before you deploy

npm run check-theme my-theme

It renders your theme against real page data and reports anything that would break, then exits non-zero, so it can run in your own CI. It is advice: the plugin's check on the site is the one that counts. The two are tested against the same list of attacks, and this kit's CI runs a theme through the real one.

What the preview can't do

The preview looks real, and WooCommerce's scripts run: the gallery, variation choices and tabs work. But nothing is behind it:

  • Add to cart gets a stand-in answer, so nothing is really added.
  • The cart, checkout and account pages show a note where WooCommerce's forms go.
  • It always shows the same sample pages: every product URL shows the hoodie.
  • Every product shows WooCommerce's placeholder image, not a photo.

To test real behaviour, run the theme with a real WordPress.

Running on a real site

You need:

  • WooCommerce and Storefront. Other themes aren't supported yet: their pages stay native. It was built and tested on Storefront 4.6.2, and the home page requires that version. Cart, Checkout and My account also need WordPress 6.9 or later.
  • The Flx Modernize plugin, 1.8.0 or later.
  • This renderer, deployed: see Flx Render (Storefront). With Vercel, set the project's Root Directory to renderer.

Renderer settings:

Variable Value
FLX_RENDER_PUBLIC_URL The renderer's own URL
FLX_RENDER_AUTH_MODE enforce
FLX_RENDER_SIGNING_KEYS The signing key shared with the plugin
FLX_RENDER_THEME Your theme's name

In wp-config.php:

define( 'FLX_MODERNIZE_WOOCOMMERCE', true );            // Catalogue pages.
define( 'FLX_MODERNIZE_WOOCOMMERCE_SHELL', true );      // Cart and My account in your design.
define( 'FLX_MODERNIZE_WOOCOMMERCE_SHELL_CHECKOUT', true ); // Checkout too: read below first.

Deploy the renderer before the plugin.

Before using this on a real store

  • Payment pages. With the checkout opt-in, the renderer's HTML is on the checkout page. Payment rules such as PCI DSS may apply to what appears there. That is for the merchant and their assessor to judge. This kit makes no claim about payment compliance.
  • Your renderer is part of the shop's security. Catalogue pages print what the renderer sends, including scripts. Someone who takes over the renderer or its hosting account could run script on those pages, on the same site as the login and checkout pages. The allowlist above protects those pages' own markup, not the rest of the site.
  • Plugins inside what you replace. A replaced product card or summary drops what other plugins print there, such as wishlist buttons or badges.

If anything goes wrong while a page is built, shoppers get the normal WordPress page.

Published from rickey29/twenty-twenty-five at commit 837705d · Flx Modernize 1.11.0 · Flx Render 1.11.0