Storefront runtime and themes
Shop storefronts are rendered by an Astro server in storefront/ (ADR-013). It reads everything through the Storefront API and renders the theme the shop chose in Settings → Storefront. It holds no database connection and no secrets except one shared key used for rate limiting.
visitor ── {slug}.flowery.com.ua ── nginx ─┬─ /control, sign-in, /storefront-api/v1, /build, /storage, /up, /release ─► Laravel or www.shop.ua (ADR-022) └─ everything else ─► Astro (Node) ── API calls over loopback ─► LaravelIn production, nginx routes only the storefront pages of shop hosts (subdomains and shops’ own domains) to the Node server and leaves every other path with Laravel. If the Node server is down (a deploy restart), Laravel answers those paths with a short “back in a moment” page carrying the shop’s phone (production cutover). The previous Laravel storefront was retired on 5 October 2026.
One request
Section titled “One request”src/middleware.ts is the runtime. Themes never see it.
-
Shop. The
Hostheader must be a well-formed{slug}.{STOREFRONT_DOMAIN}or, for a shop on its own domain (ADR-022), any well-formed hostname; the platform’s root andwwware never shops. The host is sent to the API as is, and a host no shop has is the API’s 404 (shown as the plain 404 page). The runtime still cannot be pointed at another server: API calls go toSTOREFRONT_API_ADDRESSand, withSTOREFRONT_API_SNIset, present and verify that one TLS name (flowery.com.uain production, covered by the server’s certificate) while theHostheader stays the shop’s. Without it, TLS verifies the shop host itself, which only works for subdomains. -
Runtime paths. The runtime handles a few paths itself:
/__storefront/releasereports the commit the build came from./__storefront/language?to=uk|en&back=/pathsets thelocalecookie, which the control panel shares, and returns only to a same-site path./__storefront/exit-previewends a preview./create-bouquetredirects to/builder.
-
Preview. A link with
?preview=<token>moves the token into an httpOnly, host-only cookie for an hour and redirects to the clean URL, so the token does not linger in history or referrers. When the API says the token is no longer valid, the runtime deletes the cookie. -
Configuration.
GET /shop, with the preview token when present. If the shop is unknown the visitor gets a plain 404 page; if the API is unreachable, a 503 page withRetry-After. One address. The answer names the shop’s address (domain: its own domain when live, otherwise the subdomain). A visit on any other host that reaches the same shop is answered with a301to that address, same path and query, cacheable for an hour so a later change of address is picked up. Previews are exempt: the preview cookie is host-only and members preview on the subdomain. -
Language. The visitor’s
localecookie, otherwise the shop’s default language, which is Ukrainian unless the shop chose English in its profile. -
Route. The URL scheme belongs to the runtime, so links keep working when a shop switches themes:
Path Template /home/cataloguecatalogue(query:category,occasion,q,sort,available,page)/bouquets/{id}bouquet/flowers/{id}flower/builderbuilder/cartcart/checkoutcheckout(checkout)/orders/{40-character token}order, the private status pageanything else not-found(status 404)Opening soon. While
GET /shopsaysopen: false(the shop has no active catalogue bouquet and no active flower), every page shows a static “opening soon” page instead of the theme (src/runtime/coming-soon.ts). It is in the visitor’s language and shows the shop’s name, phone (atel:link), address and opening hours, all escaped. It is not indexed (noindex) and has its own strict CSP. Order status pages and previews still render the theme, so earlier customers can follow their orders and the owner can see the design before adding products.Astro lets only the page, not a theme’s template, set the status before the response streams. So the runtime checks the bouquet, flower or order a route names first (
runtime/records.ts) and answers 404 when it is missing. Until 6 October 2026 a missing bouquet or flower showed the not-found content with status 200 (found while testing the status page). A status link is fetched once per request and shared with the template. -
Theme. The runtime rewrites the request internally to
/theme-entry/<theme>/…, the theme’s own entry page, which is not reachable directly. Each theme therefore has its own CSS bundle. A configured theme that this runtime lacks falls back to Bloom. -
Headers.
- Every page:
Cache-Control: no-cache,X-Content-Type-Options,Referrer-Policy,X-Frame-OptionsandPermissions-Policy. - Previews:
private, no-storeandX-Robots-Tag: noindex. - Checkout and status pages:
private, no-store,X-Robots-Tag: noindex, nofollowandReferrer-Policy: no-referrer, since a status page’s address carries its token. The runtime never keeps a status answer in memory.
- Every page:
What a theme receives
Section titled “What a theme receives”A theme’s Theme.astro receives one ThemeContext (from @flowery/storefront-sdk):
shop: name, currency, profile and theme configuration.theme: the active theme’s settings and enabled sections, with images resolved to{url, alt}.localeandpreview.route: template, parameters, path and the allowed query keys.api: the Storefront API client, bound to this shop.origin, for canonical and Open Graph URLs: the shop’s one address (in a preview, the host the preview was opened on).links: language switch and exit preview.
Islands (React components hydrated in the browser) use the same SDK against the same-origin API. The cart cookie rides along, and every change returns the whole cart priced by the server.
Caching
Section titled “Caching”- Catalogue and shop answers. The runtime keeps them in memory for 30 seconds per shop URL and language, at most 2,000 entries. Identical concurrent requests share one API call.
- Not cached: the cart and previews.
- After publishing, a new version shows within 30 seconds.
- Rate limits. The runtime calls the API from the server. It sends the shared
STOREFRONT_RUNTIME_KEYwith the visitor’s address (the lastX-Forwarded-Forentry, which nginx appended), so Laravel’s rate limits apply per visitor. Without the key, Laravel ignores the forwarded address.
The SDK
Section titled “The SDK”storefront/packages/sdk (@flowery/storefront-sdk) is the only package a theme imports:
- API types, generated from
docs/storefront-api/openapi.yaml(npm run sdk:generate). CI fails if the committed types differ from the contract. - A typed client. It throws
ApiErrorwith the status and field errors. - Exact money. Prices are summed as integer minor units (
bigint), never floats, and formatted for the visitor’s language. The builder’s running total is an estimate; the server prices the bouquet again when it is added. - Helpers:
paths,setting.*(typed reads of validated settings),occasionLabel, andcreateTranslatorfor a theme’s own strings, including plurals (t.choice('stems', 5), with the forms in each language’s order, for example Ukrainianone|few|many). - React hooks:
useCart: one cart shared by every island on the page, withloadingfor the first read andsavingfor changes.useBuilder: stems within each flower’smax_per_bouquet, with a functionalstep()so quick clicks all count; packaging; the estimate.useCartPanel,openCartPanelandcloseCartPanel: whether the side cart is open, shared by every island, so an add button can open the theme’s cart drawer.useModalDialog(open, onClose): a native modal<dialog>with focus kept inside, a closed scroll and an immediate close from Escape, the backdrop or a button. Used by menus and side carts.useDisclosure(): the WAI-ARIA disclosure pattern for navigation panels, closing on Escape (focus returns to the button) and on a click or focus outside.
Theme packages
Section titled “Theme packages”storefront/themes/<id>/ theme.json manifest: settings DSL, section types, defaults (read by Laravel too) Theme.astro entry: picks the template for context.route.template templates/ home, catalogue, bouquet, flower, builder, cart, checkout, order, not-found sections/ … the theme's own structure islands/*.tsx interactive parts, using the SDK hooks locales/{uk,en}.json styles.css Tailwind scoped to the theme's own filesBoundary. npm run themes:check (scripts/check-themes.mjs, also in CI and scripts/check-all) refuses:
- imports other than the SDK, React, font packages and the theme’s own files;
- reading the request, cookies, environment or process;
set:htmlor other raw HTML or code injection;- inline scripts, network calls of the theme’s own, and outside addresses.
Content Security Policy.
- Scripts run only from the storefront itself; Astro’s hydration scripts are allowed by hash.
- Styles come from the theme’s own stylesheet. Style attributes are allowed because themes set validated colours that way.
- Images may come from the storefront or any https host; product photos live on the platform’s storage host.
- Fonts come from the storefront, connections only to the storefront, and forms submit only to the storefront.
The two themes
Section titled “The two themes”| Bloom | Noir | |
|---|---|---|
| Look | Paper and ink, Piazzolla with Commissioner (or Commissioner only), hairlines, 4:5 photography (design) | Warm near-black, Prata with condensed Sofia Sans labels, optional uppercase headings, 3:4 photography |
| Home sections | Hero, categories, new arrivals, featured (×3), occasions, build-your-own, about, contacts, text (×4) | Hero, featured (×2), new arrivals, contacts |
| Catalogue | Category tabs, occasion and order menus (links), in-stock toggle, search, pages | Category and sort links, pages |
| Builder | A list of stems beside an order slip with a colour bar; a bottom bar on phones | A list with steppers |
Honest content, as in the honesty hotfix:
- Each block appears only when its data exists: no contacts without a phone, address, hours or links; no builder prompt without stems today.
- A product with no photo is a label card: a paper tone kept per product, its catalogue number, name and flowers.
- Availability is the API’s label, never a number.
- The cart says online ordering is not open yet, and lists the shop’s own contacts when it has them. Stage 3A brings checkout.