Backend for Frontend

Caching

How Orchestr caches API data — query cache, link cache, and component cache. Configure strategies and cache keys for your own integrations.

Orchestr caches results from query handlers, link handlers, and component resolvers so repeated requests skip external API calls. The cache layer is built on unstorage (Nitro's storage abstraction), so you can swap drivers (in-memory, Redis, etc.) per environment.

By default, handlers are not cached. You opt in per handler by adding a cache property.

Cache layers

Orchestr maintains four separate cache layers, all stored under the cache:orchestr:internal namespace with different prefixes:

LayerCached dataKey shapeConfigured on
QueriesQuery handler results (IDs, totals, filters, optional passthrough){token}:{buildCacheKey(args)}Query handler cache
LinksLink handler results (source/target ID mappings){token}:{buildCacheKey(args)}Link handler cache
ComponentsResolved entity components (per entity, per component){entityType}:{entityId}:{component}:{keySuffix?}Component resolver cache
Page indexEnumerated pages, search results, counts, locate results{tier}:{pageType}:{market}:{locale}:…Page index cache

The page-index layer follows its own rules: it is on by default, keys itself from the resolved market and locale rather than from a buildCacheKey, and serves stale while refreshing. See Page Index caching.

The automatic client-env segment

Every cache key carries a segment derived from the client environment, so a multi-language storefront can never serve cross-locale data:

{locale}:{currency}:{published|preview}

That is the whole segment. It deliberately does not widen as ClientEnv grows — if your handler's output varies by anything else, append your own scalar (see buildCacheKey for queries and links, getKeySuffix for component resolvers):

getKeySuffix: (clientEnv) => clientEnv.market.slug,

Return a scalar, never clientEnv itself: market and language are cyclic, so JSON.stringify(clientEnv) throws.

Content preview bypasses all three cache layers entirely — a preview request behaves as if every handler were strategy: 'live', reading nothing and writing nothing. Unpublished content is never stored and can never be served to a shopper.

Query and link handlers use the same cache config shape:

cache: {
  strategy: 'ttl' | 'swr' | 'live',
  ttl: '10 minutes',  // HumanTtl: number (seconds) or string ('1 day', '2h')
  buildCacheKey: (args) => string | null | undefined,
  includePassthrough?: boolean,  // queries only
}
StrategyBehavior
liveNo caching. Handler runs on every request.
ttlCached for a fixed duration using the storage driver's native TTL. Entry is evicted after expiry.
swrCached with an application-level expiry timestamp. On read, expired entries are removed and the handler runs again. The ttl field is optional for SWR.

buildCacheKey receives the handler arguments and must return a unique string. Return null or undefined to skip caching for that particular request.

Query cache example

export default defineMyAppQuery({
  implements: ProductsByCategorySlugQuery,
  cache: {
    strategy: 'ttl',
    ttl: '1 day',
    buildCacheKey({ input, pagination, filter, sorting }) {
      // Only cache unfiltered first page
      if (filter || (pagination && pagination.offset > 0)) return null;
      return `${input.categorySlug}:${sorting ?? 'default'}`;
    },
  },
  run: async (args) => { /* ... */ },
});
export default defineMyAppLink({
  implements: ProductVariantsLink,
  cache: {
    strategy: 'ttl',
    ttl: '1 day',
    buildCacheKey({ entityIds }) {
      return entityIds.sort().join(',');
    },
  },
  run: async (args) => { /* ... */ },
});

Passthrough and query cache

When a query handler stores data in passthrough that component resolvers depend on, set includePassthrough: true. The cache then stores and restores the passthrough dump alongside the query result. If a cached entry was stored without passthrough but the current request needs it, the cache returns a miss so the handler re-runs.

Component cache

The component cache stores resolved entity components per entity ID and component name. On each request, Orchestr:

  1. Checks the cache for all requested (entityId, component) pairs
  2. Runs component resolvers only for missing pairs
  3. Writes newly resolved components back to the cache

This means different components can have different TTLs (e.g. product names cached for a day, prices for 15 minutes).

Configuration

export default defineMyAppComponentResolver({
  entityType: 'Product',
  provides: [ProductBase, ProductPrices, ProductMedia],
  cache: {
    ttl: '1 day',
    swr: false,         // optional: use SWR semantics
    getKeySuffix: (clientEnv) => clientEnv.market.slug,
    components: {
      prices: { ttl: '15 minutes' },
    },
  },
  resolve: async (args) => { /* ... */ },
});
OptionDescription
ttlDefault TTL for all components from this resolver.
swrWhen true, uses application-level expiry instead of storage driver TTL.
getKeySuffixReceives the resolved ClientEnv and returns a suffix appended to cache keys (e.g. market slug, channel). Same entity cached separately per suffix. Must not reference handler arguments, and must return a scalar.
componentsPer-component overrides. Keys are component names (e.g. 'prices'), values override ttl and swr.
enabledSet to false to disable caching for this resolver.

Storage and drivers

Orchestr registers two storage namespaces:

  • cache:orchestr:internal: used by the three cache layers above
  • cache:orchestr:userland: for app-level cached helpers (see below)

In development, both use LRU in-memory drivers (max 5000 entries). In production, configure a persistent driver (e.g. Redis) via Nitro storage config for durable or shared caching across instances.

Clearing the cache

POST /api/laioutr/orchestr/clear-cache

Clears both internal and userland caches. Restrict access in production.

Userland cache

For data outside query/link/component results (e.g. aggregated counts, resolved SEO URLs, system config), use useUserlandCache so the data is cleared together with the orchestr cache. It returns a typed unstorage instance scoped to cache:orchestr:userland:{prefix}.

import { useUserlandCache } from '#imports';

export const getCategoryTotal = async (
  adminClient: AdminApiClient,
  categoryId: string
): Promise<number | undefined> => {
  const cache = useUserlandCache<number>('shopify/categoryTotal');

  const cached = await cache.getItem(categoryId);
  if (typeof cached === 'number') {
    return cached;
  }

  const total = await fetchTotalFromApi(adminClient, categoryId);
  if (total !== undefined) {
    await cache.setItem(categoryId, total);
  }

  return total;
};

The cache prefix keeps keys organized per app and concern. Since this is a standard unstorage instance, you have access to getItem, setItem, removeItem, getKeys, and all other unstorage methods.

For function-level caching with automatic key management, you can also use Nitro's defineCachedFunction with base: ORCHESTR_CACHE_KEY_USERLAND.

For two patterns that come up across every connector (fire-and-forget background writes via event.waitUntil, and multi-tenant cache key composition), see the Userland cache patterns recipe.
Copyright © 2026 Laioutr GmbH