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
- Open
renderer/src/theme/starter.ts. - In
productCard, change the markup, for example move the price above the name. - Save and reload
/shop/.
Your own theme
- Copy
starter.tstosrc/theme/my-theme.ts, and changenameand the export. - Register it in
src/theme/index.ts. - 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
escWpTextorescWpAttr, and URLs throughsafeUrl. - Fields ending in
Htmlor_htmlare already HTML: print them as they are. - WooCommerce's scripts still run the shop, and they look for certain markup:
- Use
addToCartLinkfor card buttons. - Print
galleryHtml,purchaseHtmlandafterHtmlwhole. - Keep the classes
summary entry-summaryon the column with the form. - Keep
product_metawith an element of classskuinside it. - Keep
id="site-navigation"and the button with classmenu-togglein the header.
- Use
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
searchHtmlandcartHtmlinheader, andhandheldHtmlinfooter, whole and exactly once: WordPress fills those in. They can't go insidea,button,p,span, a heading or an inline tag such asstrong. Insidediv,nav,ulandliis 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, andbuttonwithtype="button"or no type. So nosvg,tableorlabel. 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). Nodata-*.styleonly onheader, and only exactlyparts.style. - Ids:
page,masthead,content,colophon,site-navigation,site-navigation-menu-toggle, or startingflx-ormenu-, or a word and a number, such as a widget'ssearch-2. - URLs: on the site, with no query string, or a menu link WordPress sent. So no
mailto:ortel: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 wordsexpression,bindingorbehavioranywhere, 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