Apps and Sites
Business logic lives in the App. The front end lives in the Theme a Site runs. Keep the two apart and a storefront can change its look without touching an order rule, an order rule can change without redeploying a storefront, and every other channel, from Cockpit to an ERP workflow to a partner's own client, gets the same logic through the same API.
This page is for anyone who has asked "is this an App or a Site thing?" It explains the split, the mechanism that connects the two halves, and the failure you get when you ignore it.
Two artefacts, two jobs
| App | Site (running a Theme) | |
|---|---|---|
| What it is | One Node function plus declarative JSON contracts | A Nuxt 4 server-rendered site, deployed to a Site and activated on a domain |
| Owns | Data models, rules, HTTP capabilities under /v1, Cockpit admin screens | Blocks, layout, branding, the buyer's session, and how pages render (page content itself is data the platform stores) |
| Reaches the platform through | Its typed data client and the capabilities of other Apps | Its own server routes, which call the public API |
| Who consumes it | Storefronts, Cockpit, integrations, SDKs, AI tools: anything that speaks the API | Buyers in a browser |
| Installed how | From App Studio, per tenant | From Experience Studio, per tenant, then activated per domain |
| Documented in | App Studio | Experience Studio |
Put a piece of work on the App side when the answer must be the same no matter who asks: a price, a stock figure, a validation rule, an approval step, a data model. Put it on the Theme side when it is about how one storefront presents that answer: which blocks appear on the home page, how a product card looks, what the checkout form asks in which order.
Two examples that trip people up:
- A "spare parts compatible with my machine" list. The compatibility rule is an App capability. The Theme renders the list and lets the buyer filter it.
- A cost-centre field in checkout. Which cost centres exist and which buyer may use which is App logic, reached through a capability. The form field, its label and its position in the checkout are Theme work.
How a Theme reaches an App's logic
An App has no way to push code into a storefront, and a Theme has no way to read an App's tables. The only path between them is the public API, and that is the point.
Four things happen along that line:
- The App declares a capability. A route in your function is not public until an entry in
manifest.capabilities.jsondeclares it. The declared path is served verbatim under/v1, and the same declaration produces the OpenAPI document, the SDK methods and the API Explorer entry. See Capabilities. - The Theme declares what it needs.
requires[]intheme.jsonnames the capabilities the Theme cannot run without, with a semver range. Cockpit refuses to install the Theme on a tenant that has no App providing them, and names the App that would. See Theme anatomy. - A server route makes the call. Components never call the gateway. They call
/api/*on the storefront's own origin, and a server route there callsapi.revenexx.comwith the tenant header and a credential that stays server-side. See The BFF pattern. - The tenant comes from the request. One deployed Theme serves many tenants. When a tenant activates your Theme on one of its domains, the platform resolves the tenant from the request host and hands your server routes a scoped credential for it; a forged tenant header from outside is discarded. Locally, and on a single-tenant Site, you set the tenant and key through environment variables. Either way your code reads them per request, never once at startup. See Multi-tenant themes.
The generated Web SDK, @revenexx/sdk, wraps the same calls with types. Use it inside server routes exactly as you would $fetch. Where a call goes is the same either way.
A worked example
A stock-locations App exposes the list of warehouses and stores. A storefront shows them in a store finder block. This is the locations App from Build your first App, consumed by the Theme from Build your first Theme.
The App side: one capability
The App owns the locations entity and mounts the standard routes on it. Nothing here knows a storefront exists.
'use strict';
const { createApp, mountCrud } = require('@revenexx/app-sdk/router');
const { createDb, ENTITIES } = require('./db.generated');
function buildApp(db) {
const app = createApp({ name: 'locations' });
mountCrud(app, db.locations, {
path: '/locations',
columns: ENTITIES.locations.columns,
});
return app;
}
module.exports = async (context) => buildApp(createDb({ adapter: 'runtime', context })).handler()(context);
module.exports.buildApp = buildApp;
The capability that makes the list route public. revenexx apps capabilities --write generates this entry from schema.json and the mounted routes.
{
"type": "define",
"capability": "locations.list",
"version": "1.0.0",
"summary": "List stock locations",
"route": { "method": "GET", "path": "/locations" },
"response": { "title": "LocationList", "type": "object" }
}
Deploy and install it on your tenant:
revenexx deploy app
From here on, GET https://api.revenexx.com/v1/locations answers for that tenant, the operation appears in /v1/openapi.json, and the next SDK build has a locations.list method.
The Theme side: require, call, render
First, the Theme states its dependency. Without the App, this Theme has an empty store finder, so the dependency is hard.
{
"requires": [
{
"capability": "locations.list",
"compatible": "^1.0",
"reason": "renders the store finder on the contact page"
}
],
"permissions": [
{ "capability": "locations.list", "compatible": "^1.0" },
{ "entity": "pages", "access": ["read"] }
]
}
Second, a server route makes the gateway call. It reads the tenant and the credential from the request and the private runtime config, and it returns a page-shaped list rather than the raw envelope.
export default defineEventHandler(async (event) => {
const tenant = getTenant(event) // per request, never module scope
const config = useRuntimeConfig(event)
const page = await $fetch<{ items: Location[] }>(`${config.revenexxApiUrl}/v1/locations`, {
headers: {
'X-Revenexx-Tenant': tenant,
'X-Revenexx-Api-Key': config.revenexxApiKey, // private config, server only
},
query: { limit: 100, order: 'name.asc' },
})
return page.items.map(({ id, name, code, city }) => ({ id, name, code, city }))
})
Third, a composable gives components one call to make. It fetches the storefront's own route, so it works during server-side rendering and in the browser without knowing anything about credentials.
export function useLocations() {
return useFetch<StoreLocation[]>('/api/locations', {
key: 'locations',
default: () => [],
})
}
Fourth, the block. An editor drops it on a page in Cockpit. The component renders what the composable returns and nothing else.
<script setup lang="ts">
defineBlokkli({
bundle: 'store_finder',
editor: { mockProps: () => ({}) },
})
const { data: locations, status } = useLocations()
</script>
<template>
<section class="store-finder">
<p v-if="status === 'pending'">Loading locations…</p>
<ul v-else>
<li v-for="location in locations" :key="location.id">
<strong>{{ location.name }}</strong> <span>{{ location.city }}</span>
</li>
</ul>
</section>
</template>
Register the bundle the way Registering blocks describes, then deploy:
revenexx deploy theme
Install the Theme from Experience Studio and activate it on a domain. If the tenant has no App providing locations.list, the install stops there and Cockpit tells you which App to install first. That check is the split doing its job.
What not to do, and what breaks
Do not put business logic in the Theme. A price calculation, a stock rule or an approval condition written in a server route exists only for that storefront. Cockpit, an Integration Studio workflow and a partner's own client all get a different answer, and the day the rule changes you have two places to change it. The App is the one place; the storefront is one of its callers.
Do not couple a Theme to an App's internals. Call the declared capability at its declared route. Do not depend on a field the contract does not list, and do not guess at paths the OpenAPI document does not publish. A capability is a contract that is versioned and kept stable. Anything outside it can change on the next deploy without notice, and your Theme is the first thing to break.
Do not call the gateway from the browser. A request from a component to api.revenexx.com carries the credential, which puts the credential in the browser. Components call /api/*; server routes call the gateway. There is no exception.
Do not resolve the tenant or cache once per process. A module-scoped tenant serves the first tenant's data to everyone. A cache key without the tenant in it serves one customer's data on another customer's domain. Read the tenant per request and put it in every cache key. The API guarantees isolation between tenants; only your storefront can undo it.
Versioning and deploying the two halves
The App and the Theme are separate artefacts with separate versions, registered and deployed independently.
- The App moves strict-forward. A published contract can gain things and never change the meaning of what it published. A breaking change is a new major, a new capability key and a new route, and both versions serve traffic until every caller has moved. See Versioning.
- The Theme pins a range.
requires[].compatibleis a semver range, so a Theme built againstlocations.list^1.0keeps working through every compatible App release without a redeploy. When the App defines a2.0.0, the Theme opts in by changing the range and the call, on its own schedule. - Publishing is per artefact.
revenexx deploy appandrevenexx deploy themerun independent pipelines. A published Theme version is immutable, so a fix is a version bump, not a re-push. See Publishing a theme.
The practical rhythm: ship the App first, confirm the capability answers at /v1, then ship the Theme that requires it. Reversing the order is not harmful, only pointless, because the Theme cannot be installed until the capability exists.
Where to go next
- Build an App and Build a storefront: the two paths, with prerequisites and first commands.
- Capabilities: the App's public surface, and the difference between defining and implementing a contract.
- The BFF pattern: why components call
/api/*and where the key lives. - Multi-tenant themes: one deployment, many tenants, and the cache-key habit.
- Architecture overview: how a request flows through the platform.