Philipp Lehmann @PhilippTheServer

Building a Headless Storefront Against the OpenTaberna API

When the shop is an API with an OpenAPI document, the frontend is an ordinary web project: a typed client, a few pages, and no theme engine.

The problem

Every shop eventually wants to look like itself. With a coupled shop system, that wish turns into a project. You learn the theme engine and its template language, find out which blocks can be overridden and which ones only the vendor gets to touch, and then you discover that the product page you want needs data the template context does not expose. The fix is a plugin. The plugin needs a hook. The hook changes in the next major version, and the upgrade becomes a second project.

Worse, the frontend and the logic ship together, so every redesign is also a deployment of checkout. Anyone who has watched a CSS change break payment on a Friday afternoon knows what that coupling costs.

In the first article I argued that the website is a client of the shop, not the shop itself. This article is the practical half of that argument. If OpenTaberna’s API is finished, and the order handling behind it is already automated, how much work is left for someone who wants their own storefront?

Working through it

The contract is published, not implied

The API describes itself. FastAPI generates an OpenAPI 3 document from the same Pydantic models that validate requests, so the description cannot disagree with the code. It is built from the code. A contract test keeps the prose in it honest too. Every storefront-facing operation lives under /v1, and the admin operations under /v1/admin need a token issued to the admin client, so a storefront cannot wander into the back office even by accident.

A storefront needs a surprisingly small slice of this:

Need Endpoint
Product list and detail GET /v1/items/, GET /v1/items/{item_uuid}
Product images GET /v1/items/{item_uuid}/image
Customer profile and addresses /v1/customers/me, /v1/customers/me/addresses
Cart to order POST /v1/orders/
Pay POST /v1/orders/{order_id}/checkout, returns a Stripe client_secret
Returns POST /v1/orders/{order_id}/returns

Login is standard OIDC with PKCE against Keycloak, and payment is Stripe Elements with the client secret the checkout call returns. Neither is ours to reinvent, and we did not.

The reference storefront is small on purpose

Our own storefront is Angular 22 with Tailwind 4. Its entire API client is one file of 37 lines with twelve methods, one per call it makes. Two of them:

items(skip = 0, limit = 50) { return this.http.get<ItemPage>(`${this.base}/items/`, { params: new HttpParams().set('skip', skip).set('limit', limit).set('status', 'active') }).pipe(map((page) => ({ ...page, items: page.items.map(withApiMedia) }))); }
checkout(customerId: string, orderId: string) { return this.http.post<CheckoutResponse>(`${this.base}/orders/${orderId}/checkout`, {}, { headers: { 'X-Customer-ID': customerId } }); }

There is no business logic in there, and that is deliberate. Totals, stock and order state are the API’s responsibility. The storefront shows what it is told and asks before it acts. When our storefront gets something wrong, the worst outcome is an ugly page. The payment side is not at risk.

The admin frontend follows four rules that are worth copying: services live in core/, presentational components in shared/ui/ never call the API, feature pages never touch HttpClient directly, and templates contain no design literals. That layering has its own article. Neither frontend is special, and that is the point. You are free to replace either.

Types for free, and a compiler that reads the contract

A published OpenAPI document means you do not have to write interface definitions by hand. Generate them, and the compiler knows the API’s shapes as precisely as the server does.

The solution

Here is a storefront catalogue in the smallest form I could make that was still honest, run against the development stack from the first article with two products in it. No framework, no build step:

// catalogue.mjs
const api = process.env.API ?? 'http://localhost:8000/v1';

const page = await fetch(`${api}/items/?status=active&limit=50`).then((r) => r.json());

for (const item of page.items) {
  const price = (item.price.amount / 100).toFixed(2);
  console.log(`${item.name.padEnd(28)} ${price} ${item.price.currency}`);
}
console.log(`${page.page_info.total} products`);
node catalogue.mjs
# Enamel mug, 350 ml           14.90 EUR
# Server log book, dot grid    9.90 EUR
# 2 products

That is a working product listing. Prices are integers in cents, so there are no floating point surprises. Replace console.log with DOM output and you have a page. The rest of a real storefront is the same idea repeated: a product page, a cart held in the browser, a login redirect, and a Stripe Elements form.

For anything bigger than a demo, generate the types. openapi-typescript reads the running API and writes one declaration file:

npx openapi-typescript@7.13.0 http://localhost:8000/openapi.json -o api.d.ts
# ✨ openapi-typescript 7.13.0
# 🚀 http://localhost:8000/openapi.json → api.d.ts [209.3ms]

wc -l api.d.ts
# 10292 api.d.ts

That is ten thousand lines of interfaces nobody had to write, in two tenths of a second. Now the same catalogue, typed. It contains a deliberate typo of exactly the kind that otherwise ships as an empty price column:

// catalogue.mts
import type { components } from './api.js';

type ItemPage = components['schemas']['PaginatedResponse_ItemResponse_'];

const api = 'http://localhost:8000/v1';

export async function catalogue(): Promise<ItemPage['items']> {
  const res = await fetch(`${api}/items/?status=active&limit=50`);
  const page: ItemPage = await res.json();
  return page.items;
}

const items = await catalogue();
for (const item of items) {
  console.log(item.name, item.price.amount, item.price.curency);
}
npx -p typescript@7.0.2 tsc --noEmit --target es2022 --module nodenext \
  --strict --lib es2022,dom catalogue.mts
# catalogue.mts(15,56): error TS2551: Property 'curency' does not exist on type
#   '{ amount: number; currency: string; includes_tax: boolean;
#      original_amount?: number | null | undefined;
#      tax_class: "none" | "reduced" | "standard"; }'.
#   Did you mean 'currency'?

The compiler knows the API’s price model, down to the three tax classes, because the API told it. Fix the typo and tsc exits 0. Regenerate api.d.ts in CI, and a backend change that would break your storefront breaks your build first, while it is still cheap.

That is the whole adaptation story. Pick any framework, or none. Point it at /v1, sign in against Keycloak, and let Stripe handle the card field. Stock, payment confirmation, labels, tracking emails and returns keep working whatever the page looks like, because the page was never involved in any of them.

Conclusion

A finished API turns a redesign into a web project. No theme engine, no template language, no hooks to wait for. If you know how to build a website, you already know how to build an OpenTaberna storefront.

Keep the storefront stupid. Our reference client is 37 lines because it contains no decisions. Everything that can cost money lives behind the API, which is the only place it can be tested once and trusted everywhere.

Let the contract do the typing. A generated api.d.ts makes the OpenAPI document the single source of truth for both sides. The typo you would have found in production turns up at compile time instead.

The storefront we ship is a starting point, not a requirement. If you build a different one, I would honestly like to see it. The repositories are at github.com/OpenTaberna.