storefront-service is the SSR rendering engine for every store. It owns all layout data — theme files, section trees, Lua resolvers — and produces fully rendered HTML for every storefront request. It has no dependency on a client-side rendering framework. Dynamic interactions use HTML fragment endpoints consumed by Hotwire Turbo or htmx, so the browser always holds server-rendered HTML regardless of whether an interaction is a full navigation or a partial update.
Rendering Model#
Every page is rendered server-side and delivered as complete HTML. There is no client-side hydration step and no virtual DOM.
Fragment endpoints return small HTML snippets for dynamic interactions (cart drawer updates, live search, form submissions). The browser applies them via Hotwire Turbo or htmx without a full page reload.
Template Engine#
All templates are written in Pebble (quarkus-pebble extension). Pebble uses a Liquid-like syntax that is intentionally familiar to store operators coming from Shopify — existing Liquid knowledge transfers directly.
Every template executes inside a PebbleSafePolicy sandbox. The policy removes filesystem access, Java reflection, and arbitrary Java method calls. Template authors work entirely within the Pebble expression language and cannot reach the JVM.
Sections and Blocks#
Page Composition#
A page is an ordered list of Sections. Each section carries:
| Field | Description |
|---|---|
type | Maps to a Pebble template file and a Java DataResolver |
settings | JSON blob of user-configured values (colours, text, toggles) |
blocks | Optional ordered list of sub-components within the section |
Each Block is a sub-component within a section with its own type and settings blob. Blocks are rendered inside the parent section’s template.
Variable Cascade#
Variables flow down the following chain at render time:
theme.settings → page → section.settings / section.data → block.settingsThe Pebble | default: filter drives fallback at each level. A block template can read section.settings, section.data, and theme.settings directly. Blocks cannot read the settings or data of sibling sections.
Schema Files#
Every section type and block type ships with a settings JSON schema. The schema defines which settings keys exist, their types, default values, and the UI controls Easy CMS renders for them. It is the contract between the template author and the editor.
Dynamic Data#
Sections that need live data declare a data_source setting in their schema. At render time, storefront-service invokes the DataResolver registered for that section type. The resolver makes calls — typically via Catalog gRPC or another internal service — and injects the result into section.data before Pebble renders the template.
All DataResolver calls are server-side and happen synchronously at render time. There is no client-side data fetching for section data.
Themes#
Structure#
Themes are versioned bundles of template files, schema files, and static assets. The platform ships base themes. Stores can override any file in a base theme without forking it. All store overrides go through the sandbox asset pipeline before going live.
Resolution Order#
store override → base theme → platform defaultWhen resolving a template file, storefront-service checks the store’s override layer first, then the base theme, then the platform default. A store can change any single template without touching the rest of the theme.
Easy CMS and Code Coexistence#
| Mode | Writes | Reads |
|---|---|---|
| Easy CMS | Section tree JSON (section order + settings values) | Schema files — to know which controls to render |
| Code editor | Template .pebble files and schema .json files | — |
The two write paths are fully segregated. Easy CMS never writes template files. The code editor never writes section tree JSON. Neither path can corrupt the other.
Sandbox Integration#
storefront-service is a sandbox participant. When a request carries a valid signed preview token, the service reads from the sandbox asset layer instead of the live published layer — templates, schemas, Lua resolvers, section trees, and static assets. The resulting preview URL is shareable with any stakeholder who has the token, without requiring a platform login.
At sandbox publish time, content-service calls storefront-service via the SandboxParticipant gRPC interface to apply or roll back theme asset overrides and section tree changes atomically.
Store-Level Custom Code#
{% fetch %} Tag#
The {% fetch %} tag is a Pebble template extension for simple outbound HTTP. It makes a GET request to an endpoint on the store’s HTTP allowlist and binds the response as a template variable. It has no conditionals, no loops, and no side effects — the right tool when a store needs to pull a small piece of external data (e.g. a loyalty point balance) into a template without writing a full Lua resolver.
Lua Resolvers#
For business logic that requires branching, session context, or composition of multiple data sources, stores write Lua resolvers executed via LuaJ (pure JVM — no native Lua process).
The execution environment is stripped:
io,os,debug,packageglobals removed- Instruction count capped per invocation
Two built-in functions are available:
| Function | Description |
|---|---|
fetch(url) | Outbound HTTP GET to an allowlisted endpoint |
resolve(name) | Call another named resolver in the same store |
Resolver output is injected into section.data and rendered by Pebble exactly like DataResolver output. Circular call graphs are rejected at save time — the platform performs a cycle check when the resolver is published.
All resolver files are authored in the Monaco in-app editor and go through the sandbox asset pipeline before going live.
Client-Side JavaScript#
Store-level JavaScript runs in the browser. There is no build step and no bundler — files are raw ES modules served from content-service.
File Structure#
| File | Scope |
|---|---|
assets/store.js | Runs on every page of the store |
sections/<type>/section.js | Runs only when that section type is present on the page |
SS3 Event Bus#
The platform injects a global SS3 event bus for cross-section communication:
SS3.on('cart:updated', (payload) => { /* ... */ });
SS3.emit('cart:updated', { count: 3 });
SS3.off('cart:updated', handler);Lifecycle Hooks#
| Hook | Called when |
|---|---|
onLoad(container) | Section enters the DOM |
onUnload(container) | Section is removed from the DOM |
onBlockSelect(block) | A block is selected in Easy CMS |
onBlockDeselect(block) | A block is deselected in Easy CMS |
Security Constraints#
CSP blocks eval and inline scripts. External script domains are on a per-store allowlist managed by the store admin.
Custom Domain Routing#
Each store can serve its storefront from one or more custom domains managed by store-service. At runtime, the gateway resolves the incoming Host header to a store by calling store-service’s GetStoreByDomain gRPC method, then injects the resolved X-Store-Id header before forwarding to storefront-service. No additional lookup is required in storefront-service — it receives the store context through the standard header pipeline.
Provisioning Flow#
1. Admin adds domain via store-service: POST /stores/{id}/domains
2. store-service creates the store_domain record (ssl_status: PENDING)
3. store-service requests an ACM certificate for the domain
4. ACM issues a DNS CNAME validation record
5. store-service returns the CNAME to the admin for DNS configuration
6. ACM validates the domain and issues the certificate (ssl_status: ACTIVE)
7. CloudFront distribution is updated to serve the domain with the ACM cert
8. Incoming traffic on the custom domain is routed through the standard
gateway → storefront-service pipeline with X-Store-Id injectedDomain Resolution Cache#
The gateway caches the domain → store_id mapping with a 60-second TTL. On cache miss it calls GetStoreByDomain. When a domain is removed from store-service, the gateway cache expires within the TTL window — no active invalidation.
Schema#
CREATE TABLE base_theme (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(128) NOT NULL,
version VARCHAR(32) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE store_theme (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
store_id UUID NOT NULL UNIQUE,
base_theme_id UUID NOT NULL REFERENCES base_theme(id),
name VARCHAR(128) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Template files (.pebble), schema files (.json), theme settings
-- Store overrides shadow base_theme files at the same path
CREATE TABLE theme_asset (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
theme_id UUID NOT NULL REFERENCES store_theme(id) ON DELETE CASCADE,
store_id UUID NOT NULL,
path TEXT NOT NULL,
content TEXT NOT NULL,
content_type VARCHAR(64) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (theme_id, path)
);
CREATE TABLE storefront_page (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
store_id UUID NOT NULL,
slug TEXT NOT NULL,
title VARCHAR(512),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (store_id, slug)
);
CREATE TABLE page_section (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
page_id UUID NOT NULL REFERENCES storefront_page(id) ON DELETE CASCADE,
store_id UUID NOT NULL,
type VARCHAR(128) NOT NULL,
settings JSONB NOT NULL DEFAULT '{}',
position INT NOT NULL,
UNIQUE (page_id, position)
);
CREATE TABLE section_block (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
section_id UUID NOT NULL REFERENCES page_section(id) ON DELETE CASCADE,
store_id UUID NOT NULL,
type VARCHAR(128) NOT NULL,
settings JSONB NOT NULL DEFAULT '{}',
position INT NOT NULL,
UNIQUE (section_id, position)
);
CREATE TABLE lua_resolver (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
store_id UUID NOT NULL,
name VARCHAR(128) NOT NULL,
source TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (store_id, name)
);REST API#
Theme Management#
| Method | Route | Description |
|---|---|---|
GET | /storefront/theme | Get active store theme |
GET | /storefront/theme/assets | List theme assets |
GET | /storefront/theme/assets/{path} | Get asset content |
PUT | /storefront/theme/assets/{path} | Create or update asset |
DELETE | /storefront/theme/assets/{path} | Delete store override (reverts to base) |
POST | /storefront/theme/reset | Reset all store overrides to base theme |
Pages#
| Method | Route | Description |
|---|---|---|
GET | /storefront/pages | List pages |
POST | /storefront/pages | Create page |
GET | /storefront/pages/{id}/sections | Get section tree |
PUT | /storefront/pages/{id}/sections | Replace full section tree |
PATCH | /storefront/pages/{id}/sections/{sectionId} | Update section settings |
Lua Resolvers#
| Method | Route | Description |
|---|---|---|
GET | /storefront/resolvers | List resolvers |
POST | /storefront/resolvers | Create resolver |
PATCH | /storefront/resolvers/{id} | Update resolver source |
DELETE | /storefront/resolvers/{id} | Delete resolver |
gRPC Interface#
service SandboxParticipant {
rpc ApplySandbox(ApplySandboxRequest) returns (ApplySandboxResponse);
rpc RollbackSandbox(RollbackSandboxRequest) returns (RollbackSandboxResponse);
}Kafka Events Consumed#
| Topic | Source | Action |
|---|---|---|
store.provisioned | store-service | Seed default theme and homepage section tree |
content.sandbox.published | content-service | Invalidate page render cache for the store |
store.settings_updated | store-service | Invalidate cached store settings |
Vault Configuration#
| Key path | Purpose |
|---|---|
ss3/kv/storefront-service/db.url | PostgreSQL JDBC URL |
ss3/kv/storefront-service/db.username | Database username |
ss3/kv/storefront-service/db.password | Database password |
ss3/kv/storefront-service/redis.url | Redis URL for page render cache |
ss3/kv/shared/ | Shared Kafka bootstrap and OTel config |