@eldrajs/theme-nuxt

Nuxt module for Eldra themes: wires @eldrajs/vite-plugin-theme, the gateway client, draft overlays and the Studio preview bridge into a Nuxt 4 site.
Install
npm install @eldrajs/theme-nuxt
Latest Version:0.3.4
Vue:3
License:MIT
Last Updated:Oct 27, 2026
Links: GitHub  ·  npm
Maintainer: nokkvi

@eldrajs/theme-nuxt

Nuxt module for Eldra themes: wires @eldrajs/vite-plugin-theme, the gateway client, draft overlays and the Studio preview bridge into a Nuxt 4 site. It prerenders every published page and dynamic route from the CMS, writes the generated .eldra/manifest.json and the CSP frame-ancestors header the Studio preview bridge needs, and exposes useEldraPage for resolving the current route's page/template/entry.

pnpm add @eldrajs/theme-nuxt

Usage

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@eldrajs/theme-nuxt'],
  eldra: {
    gatewayUrl: process.env.ELDRA_GATEWAY_URL,
    orgId: process.env.ELDRA_ORG_ID,
    studioOrigins: ['https://acme.eldracms.com'],
  },
});

Environment: ELDRA_GATEWAY_URL and ELDRA_ORG_ID are required for a real build. Point ELDRA_GATEWAY_URL at the edge API host: the edge recognises the build token (X-Eldra-Build-Token, its own rate bucket), so the build's reads and the visitor's browser both go through the same host.

During a prerender the page list, route-template list and category list are read once per build (and locale), not once per route (@eldrajs/theme-nuxt/prerender); request-time rendering never shares reads. One process is one build: the shared reads live for the length of one nuxi generate and are cleared when a prerender starts. The build logs one line with the counts ([eldra] prerender shared reads: N made, M avoided).

<script setup lang="ts">
import { EldraLayout } from '@eldrajs/theme-vue';
const { page, layout, blocks } = useEldraPage();
</script>
<template>
  <main>
    <EldraLayout v-if="page && layout" :layout="layout" :blocks="blocks" />
  </main>
</template>

eldraRouteKey — required on your <NuxtPage>

Give your app.vue the route key this module auto-imports:

<!-- app/app.vue -->
<template>
  <NuxtPage :page-key="eldraRouteKey" />
</template>

This is not cosmetic on a generated site. nuxi generate writes each route as <route>/index.html, and static hosts disagree about which URL that file lives at — some serve /products/ash-glaze-mug, others answer it with a redirect to /products/ash-glaze-mug/. When the URL the visitor lands on differs from the path the page was prerendered at, Nuxt re-navigates between the two while the page hydrates; a catch-all page's default route key interpolates the splat parameter, so those two spellings key differently and Vue destroys and re-creates the page — and every block on it. Each block's setup then runs twice, so every read a commerce block makes goes out twice. Only one of the two instances ever mounts, so nothing in the DOM gives it away.

eldraRouteKey(route) keys by the canonical path — the same identity useEldraPage() resolves content under — so the move is a no-op. Themes scaffolded before this was added should add the binding.

Route resolution on a generated site

On a static build in a browser, useEldraPage() resolves routes from the build, not from the gateway. nuxi generate ships the list of paths it prerendered (Nuxt's app manifest, /_nuxt/builds/meta/<buildId>.json), and that list answers both questions the resolver used to ask the gateway:

  • The path is in the build. Its page, template and entry are in the _payload.json Nuxt's own payload plugin loads in router.beforeResolve, before the page component exists — so a client navigation between two prerendered routes renders the destination in the same tick, with pending never true and no gateway read at all.
  • The path is not in the build. It does not exist: useEldraPage() returns the empty route (page === null && template === null) straight away, so the theme draws its not-found shell without the five reads it used to take to conclude the same thing. A route published since the build is not in the artifact either — publishing triggers a rebuild, which is what makes the build the authority.

Three situations keep the old dynamic resolution, and are the reason the manifest is consulted rather than assumed:

  • Studio preview. Inside an allowed studioOrigins frame the route may be a draft, an unpublished page or a path no build has ever contained, so every resolution is live — from the first render, before the bridge has even said hello.
  • nuxi dev. The dev server ships a manifest that prerendered nothing.
  • An SSR deployment (nuxt build plus a Node server), for the same reason: nothing is in the build, so an empty prerendered list means "this build prerendered nothing", never "this route does not exist".

A resolution that failed is not a route that is missing

The empty route above is an answer: there is no such page. A read the gateway could not answer is not, and useEldraPage() keeps the two apart — a failed resolution carries error on the resolution itself (ResolvedEldraRoute.error), so error is non-null while page/template are null and a theme can draw its error branch instead of its not-found shell. The error lives on the resolution rather than inside the composable because useAsyncData keeps one entry per route key and runs only the first caller's handler: a theme that calls useEldraPage() twice on a page (a plugin for the catalog route beside the page component) would otherwise see the failure in one call and an ordinary empty route in the other.

On the server such a route is answered 500. Nitro's prerenderer marks a non-200 route failed before it writes anything, so a page the gateway could not be asked about is absent from the artifact and named in the prerender log, instead of being written as the theme's not-found shell under a path a visitor can reach. Pair it with nitro.prerender.failOnError if a build must not ship without every page — the status alone turns a wrong page into a missing one, and that flag turns a missing one into a failed build. Note the real exposure is about twice the route count: with crawlLinks: false Nitro still prerenders each page's own _payload.json, which resolves the route again.

A build with no ELDRA_GATEWAY_URL / ELDRA_ORG_ID is exempt from both: there every read fails by definition, so such a route resolves to the plain empty route with no error and the theme renders its static shell, which is the whole point of a scaffold build.

This is also why eldraRouteKey above is required rather than advisory: one page component per canonical route is what lets the composable resolve the route its page is being created for. Nuxt hands a page its own route through an injection private to Nuxt's app module, which a composable shipped in a package cannot reach; useEldraPage() therefore reads the router's committed route and pins it for the page's lifetime.

When the gateway rate-limits the build

The gateway limits a client to a number of requests per minute. A generate of a real site is thousands of reads from one address — every page, every route template, every catalog document, times the site's locales, and roughly twice over because each page's _payload.json resolves its route again — so a large build meets that limit routinely. The gateway's answer is 429; with failOnError set, one of them used to end the build.

Nothing has to be configured for this. Every gateway read — this module's own prerender reads, the theme's reads at request time, and the platform read behind commerce/locales — retries an idempotent request that answered 429 or 503 (and a dropped connection), honouring Retry-After and otherwise backing off exponentially with jitter, five attempts including the first. A rate limit is waited out rather than fought.

The knob exists for a gateway with an unusually narrow limit, or for a build that should fail fast:

// nuxt.config.ts
eldra: {
  // The defaults. `attempts` counts the first request; `{ attempts: 0 }` turns retrying off.
  retry: { attempts: 5, baseDelayMs: 250, maxDelayMs: 5000 },
}

It is carried in the public runtime config, so a storefront that builds its own @eldrajs/sdk client for commerce reads can hand it the same policy (useRuntimeConfig().public.eldra.retry) — the starter does.

Two Nitro knobs sit beside it, and neither is this module's to set: nitro.prerender.concurrency (1 by default, which is already the gentlest setting — there is nothing to lower unless you have raised it) and nitro.prerender.retry/retryDelay (3 × 500 ms by default), which re-render a route whose response was one of ofetch's retryable statuses — 408, 409, 425, 429, 500, 502, 503, 504, and so the 500 this module answers a failed resolution with, but not a 404.

The new failure mode is a build that crawls, not one that fails. A Retry-After is honoured up to a minute, and maxDelayMs caps the computed backoff rather than a wait the gateway asked for, so four waits on a limit that keeps asking for a minute is about four minutes on one read — times Nitro's own re-renders, at concurrency: 1, with no output and no error meanwhile. That is the intent (a build that waits beats a build that fails), but it is the one thing nobody can diagnose from the outside, so retry: { attempts: 0 } is the fail-fast lever: one request, and the 429 surfaces the way it used to.

The same policy governs reads a shopper triggers, not only a build: the runtime plugin's client serves SSR, a client-side route change and the Studio preview. A visitor who meets the rate limit on a client-side navigation therefore waits — up to a minute on one Retry-After — where before they were shown an error, which for a transient limit is the better of the two. A site that would rather fail fast in the browser than wait can set a smaller attempts; the prerender reads use the same value, so it is one trade-off, not two.

Catalog-backed route templates

A route template whose schemaApiId is catalog:product, catalog:collection or catalog:category is backed by the store's catalog rather than a CMS schema: the module prerenders one route per active product, collection or category, and useEldraPage() resolves the object from the path.

<script setup lang="ts">
const { template, entry, catalog, layout, blocks } = useEldraPage();
// catalog is { kind: 'product' | 'collection' | 'category', slug, path? } here, and null on every
// other route. `path` is set for a category and for nothing else.
// entry.data carries the binding paths a catalog template addresses —
// product: slug, title, description, status, categoryId, tags, variants[], images[]
// collection: slug, title, description, productCount, image
// category: slug, title, path, ancestors[], children[] (+ description as plain text, and
//           productCount, when the catalog read carries them)
</script>

A slug the catalog does not know resolves to the empty route, so the theme renders its not-found shell. During generation a slug that cannot become a route is skipped with a warning naming it and the build continues.

Categories are nested, and canonical only

A category route's pattern ends in a catch-all parameter — /categories/:path* — and its slugField is path. The parameter matches one or more segments and carries them joined with /: a category's canonical path is the slugs of its ancestors, root first, then its own (/categories/billinn/bilstolar).

There is no read that takes a path, so the whole category list is read once per request and the path's segments are walked down the tree by slug. Only the canonical path resolves — a leaf on its own, a wrong parent and a trailing extra segment are each the not-found shell rather than a redirect — and the prerender pass generates exactly those canonical paths, one per category, times the site's locales. A category the list cannot place (no slug, or a parent the list does not hold) has no route at all, and neither has the subtree under it.

A link field pointing at a category resolves through the same path: collectLinkTargets reads the tree whole for that reason, and a target it cannot place renders as plain text rather than as a link to a path the site answers with its not-found shell.

studioOrigins must be explicit — this module never supplies a broad fallback origin for the preview bridge. See examples/starter-nuxt for a full theme and docs/themes.md for the integration guide.

What the store sells in

The module reads the organisation's commerce settings once, during the build, and puts them on the runtime config beside the gateway URL and the org id. StoreCommerce is exported for exactly this, so the key's shape is not re-declared per theme:

import type { StoreCommerce } from '@eldrajs/theme-nuxt/commerce';

const raw = (useRuntimeConfig().public.eldra as { commerce: StoreCommerce | null | '' }).commerce;
const commerce: StoreCommerce | null = typeof raw === 'object' ? raw : null;

commerce is null when the store has not configured commerce, when the site is built without gateway credentials, or when the read failed — the build prints one warning (naming the cause when there was one) and finishes, because a currency nobody can fetch should cost a symbol, not a deploy. A theme that formats prices should treat that as "render a plain number", never as a currency to guess at; the starter does exactly that in app/storefront/money.ts.

One detail of Nuxt's own serialisation matters when you read the key: a null public runtime-config value reaches the page as an empty string, so on a store that published nothing commerce is '' rather than null (the module's locale does the same). Treat anything that is not an object as "no currency" — not as a malformed record — and only complain about a record that is present but incomplete.

The module supplies no override of its own, on purpose: the platform publishes the currency the catalogue's prices are actually in, so a theme-side setting could only ever relabel real amounts. (Nuxt's own public-runtime-config environment overriding — NUXT_PUBLIC_ELDRA_COMMERCE_CURRENCY and its siblings — still applies wherever Nuxt reads runtime config at request time, as it does to every other public key. On a prerendered site there is nothing for it to override: the value is baked into each page's payload at build.) It is read at build rather than in the browser because every price on a prerendered page is formatted against it.

Which locales the site serves

The module reads the organisation's configured content locales once, during the build, and puts them on the runtime config beside commerce. StoreLocales is exported for exactly this:

import type { StoreLocales } from '@eldrajs/theme-nuxt/locales';

const raw = (useRuntimeConfig().public.eldra as { locales: StoreLocales | null | '' }).locales;
const locales: StoreLocales | null = typeof raw === 'object' ? raw : null;

locales is { default, supported } with supported default-first, or null — for an organisation that has configured none, for a site built without gateway credentials, and for a read that failed (that last one, and only that one, prints a warning naming the cause). null means the site behaves exactly as it did before locales existed: one unprefixed site, no language switcher. The same empty-string serialisation note as commerce applies, which is what the typeof guard above is for.

A theme rarely needs to read the key itself: the module turns it into routing (see below) and useEldraLocale() is the composable that answers "which locale is this page, and what are the others".

Locale-prefixed routing

With two or more locales the site serves the default locale at / and every other supported locale under a path prefix — /is-IS/products/ash-glaze-mug. The first path segment selects the locale (matched case-insensitively, canonicalised to the spelling the organisation stored) and the rest of the path resolves exactly as it does unprefixed; every gateway read made for that page carries that locale. /<default-locale>/… is deliberately not generated and resolves as an unknown path, because the default locale lives at / only.

Path segments are not translated: a page is at its default-locale slug under every prefix, so /is-IS/about is the Icelandic rendering of the same document /about serves. The prerender pass therefore lists each content path once and writes it once per locale — pages, route templates, catalog routes and the seeded cart/wishlist/search pages alike.

eldra.locale / ELDRA_LOCALE keeps the meaning it always had: an override of the default locale. Naming one of the organisation's supported locales moves that locale to / and prefixes the others; naming anything else (or deploying against an organisation with no locales) changes no URL and still forwards the value on every read.

The module gives every page <html lang> and a rel="alternate" hreflang link per supported locale (plus x-default for the unprefixed path) on its own — a theme adds nothing for either.

<script setup lang="ts">
// Auto-imported, like `useEldraPage`.
const { active, defaultLocale, supported, name, path, switchPath, select } = useEldraLocale();

// `path()` prefixes one same-site destination for the active locale, and is idempotent.
const cartHref = computed(() => path('/cart'));
// `switchPath()` is the same page in another language, query and fragment included;
// `select()` navigates there.
</script>

name(locale) is that locale's own name ("íslenska (Ísland)") for a switcher's option label. It is resolved once, on the server, and carried in the payload: Intl.DisplayNames is ICU data and a renderer and a browser need not have the same of it — Node answers "íslenska (Ísland)" for is-IS where a reduced-ICU browser build answers "Icelandic (Iceland)" — so a page that let each side compute its own labels would hydrate into a mismatch and repaint.

Links are already handled where the kit owns them: useEldraLink() resolves a link field's href under the active locale, and a theme's router-link component should put every internal to through path() once (the starter's app/components/EldraRouterLink.vue is the reference).

Development

src/runtime/** is compiled by Nuxt at build/dev time, not by tsc — it imports nuxt/app and Nuxt's virtual modules (#imports, virtual:eldra/*), which only exist inside a Nuxt build graph. tsconfig.json's include is narrowed to src/index.ts and src/module.ts (the module entry, which only imports resolvable packages — and, through it, the framework-free runtime files it shares with the composable) so pnpm typecheck proves the part tsc can actually resolve; the runtime files that reach for Nuxt are still type-checked as part of nuxt-module-build build and by consuming Nuxt apps (see examples/starter-nuxt's nuxi typecheck).

Related Packages

@eldrajs/theme-vue

Vue 3 bindings for the Eldra theme SDK: block zones, layouts, rich text and the...

0 0
nuxt-microcms-module

microCMS integration for Nuxt.js

23,457 29
@flexacms/nuxt

Nuxt module for the Flexa headless CMS: auto-imported composables and a read-onl...

370 0
@spearly/nuxt-module

This package allows you to easily implement Spearly in your nuxt project!

2,092 0
nuxt-cms-engine

CMS engine for nuxt framework

3,496 0

Version History

Version Released Vue License
0.3.4 ^3.0 MIT
0.3.3 ^3.0 MIT
0.3.2 ^3.0 MIT
0.3.1 ^3.0 MIT
0.3.0 ^3.0 MIT
Pre-releases (1)
Version Released License
0.0.0-stage