The BFF pattern

Why storefront components call /api/* on their own origin and never the gateway — what the backend-for-frontend layer is for, and where the API key lives.

This is the most important revenexx-specific idea in the whole section. The storefront doesn't talk to the platform's APIs from the browser; it has a server layer of its own — the backend-for-frontend — that sits in between.

That layer exists because the browser and the platform APIs want different things. The browser wants one tidy response shaped for the page it's rendering. The platform exposes several granular, normalized APIs. The BFF is where you bridge that gap, and it's also where anything secret stays, safely server-side.

The shape

text
┌─────────────────────────────────────────┐
│        Customer Browser                 │
│  (Desktop / Mobile / PWA)               │
└────────────────┬────────────────────────┘
                 │
         HTTP/HTTPS (SSR/CSR)
                 │
┌────────────────▼────────────────────────┐
│      Nuxt 4 Storefront (BFF)            │
│  ┌──────────────────────────────────┐   │
│  │  File-based routing              │   │
│  │  /products  /checkout  /account  │   │
│  └──────────────────────────────────┘   │
│  ┌──────────────────────────────────┐   │
│  │  Blökkli pages (editable)        │   │
│  │  Home / Category / editorial     │   │
│  └──────────────────────────────────┘   │
│  ┌──────────────────────────────────┐   │
│  │  Server routes (the BFF)         │   │
│  │  - Cart aggregation              │   │
│  │  - Checkout orchestration        │   │
│  │  - Authentication                │   │
│  │  - Search                        │   │
│  └──────────────────────────────────┘   │
└────────────────┬────────────────────────┘
                 │
     REST over https://api.revenexx.com
     (X-Revenexx-Tenant on every call)
                 │
    ┌────────────┼──────────────┐
    │            │              │
    ▼            ▼              ▼
┌──────────┐ ┌──────────┐ ┌────────────┐
│  Pages   │ │ Products │ │ Carts /    │
│ delivery │ │ Prices   │ │ Orders     │
│ (page    │ │ Inventory│ │            │
│ content) │ │          │ │            │
└──────────┘ └──────────┘ └────────────┘

The rule

Components call /api/* on your own origin. Server routes call the gateway.

There is no exception to this, and the reason is one line long: a request from the browser to api.revenexx.com would have to carry the credential, which means the credential would be in the browser.

The base layer already follows the rule for everything it ships. Its composables fetch its own server routes, and those routes hold the platform client. When you add a data source of your own — a customer's ERP, a PIM, a shipping provider — add a server route for it too.

Four jobs land in the BFF

Cart aggregation is the clearest example. A cart on screen needs product details from the catalog and live stock from inventory, combined into a single object the page can render, with the cart's own state synced between the browser and the server. Rather than make the browser fire several calls and stitch the results together, a server route does it once:

server/api/cart/index.get.ts
export default defineEventHandler(async (event) => {
  const config = useRuntimeConfig(event)
  const cart = await readCartSession(event)

  // Server-side only — the key never reaches the browser.
  const headers = {
    'X-Revenexx-Tenant': config.revenexxTenant,
    'X-Revenexx-Api-Key': config.revenexxApiKey,
  }

  const ids = cart.items.map(item => item.id)
  const [products, inventory] = await Promise.all([
    $fetch(`${config.revenexxApiUrl}/v1/products`, { headers, query: { ids } }),
    $fetch(`${config.revenexxApiUrl}/v1/inventories`, { headers, query: { ids } }),
  ])

  return {
    items: cart.items.map(item => ({
      ...item,
      product: products.data.find(p => p.id === item.id),
      inStock: (inventory.data.find(i => i.id === item.id)?.available ?? 0) > 0,
    })),
  }
})

One request from the browser, one page-shaped response, two fan-out calls the browser never sees.

Checkout orchestration is the second job, and the most delicate: the BFF runs the multi-step validation across address, delivery, payment, and confirmation, coordinates between the payment provider and the orders service, and rolls back if a step fails — logic that has no business running in the browser.

Authentication and session is the third: login and logout flows, issuing and validating session cookies, and keeping each buyer pinned to the right tenant.

Search is the fourth, where the BFF handles pagination, filtering, faceted search, and autocomplete.

In every case the pattern is the same: the browser asks the BFF a page-shaped question, and the BFF does the fan-out.

Where the key lives

The credential sits in Nuxt's private runtime config, which Nitro never serializes to the client:

nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    revenexxApiUrl: 'https://api.revenexx.com',
    revenexxTenant: '',
    revenexxApiKey: '',      // private — server only
    public: {
      // anything here IS sent to the browser
    },
  },
})

Read it with useRuntimeConfig(event) inside a server route. Three failure modes to avoid:

  • Never put the key under public. That is the one-line version of the whole page.
  • Never return it in a response body, not even for debugging. A response that includes it is a response that gets cached.
  • Never log it. Request and response detail — including headers — is captured in request logs.

On a deployed site you don't provision the key at all: the platform injects a scoped one for the site's runtime. See Environment variables.

Server routes

Nuxt server routes live in server/api/ and server/routes/:

server/api/cart/items.post.ts
export default defineEventHandler(async (event) => {
  const { productId, quantity } = await readBody(event)

  await addCartItem(event, productId, quantity)
  return { success: true }
})

Running this logic in a server route rather than the browser pays off three ways: your API secrets stay server-side and never reach the client, you avoid an extra network hop so latency drops, and error handling and logging happen in one place you control.

Tenant scoping is automatic

Every platform call from the storefront carries the current tenant, and the platform scopes every response to it. You never filter by tenant in application code.

TypeScript
const products = await $fetch('https://api.revenexx.com/v1/products', {
  headers: {
    'X-Revenexx-Tenant': 'customer-a',
    'X-Revenexx-Api-Key': config.revenexxApiKey,
  },
})
// Returns only products visible to customer-a

A request without the tenant header is rejected rather than silently answered across tenants. That means:

  • Customer A never sees Customer B's products.
  • Each tenant's data is isolated.
  • No cross-tenant leakage is possible through the API.

What the API guarantees, your own caching can still undo — see Multi-tenant themes.

The full header contract is in Authentication.

Rules that keep the BFF fast

Four habits, and the first is the one that matters most on a commerce storefront:

  • Catalog in SSR, offer deferred. Names, images and descriptions are cheap and cacheable; price and stock are buyer-specific and slow. Render the catalog server-side, then resolve price and stock after the page paints.
  • Batch per page, not per component. One offer request for the visible products, never one per card.
  • Cache the cacheable, and key it by tenant. Catalog and pages responses are worth caching; anything buyer-specific is not.
  • Fail soft. A failed price lookup should render "price on request", never a zero and never a spinner that never resolves.

The worked implementation of all four is in Customize blocks.

Next steps

Was this page helpful?