Astro integration layer for Localess. A full Astro Integration (localess() in astro.config.mjs), matching @storyblok/astro's architecture — see ADR 006 for why and how it differs where Localess's constraints require it.
Peer dependency: Astro 6 or 7 (astro@^6.0.0 || ^7.0.0). Node.js: >= 24. Package version 4.0.2, in lockstep with the other @localess/* packages.
npm install @localess/astro// astro.config.mjs
import { defineConfig } from 'astro/config';
import { localess } from '@localess/astro';
export default defineConfig({
integrations: [
localess({
origin: process.env.LOCALESS_ORIGIN,
spaceId: process.env.LOCALESS_SPACE_ID,
token: process.env.LOCALESS_TOKEN,
enableSync: true,
}),
],
});localess() (alias localessIntegration()) takes LocalessOptions (src/models/client.ts) — @localess/client's LocalessClientOptions plus Astro-specific settings:
| Option | Type | Default | Purpose |
|---|---|---|---|
origin |
string |
— | Localess instance URL (protocol + host + port). |
spaceId |
string |
— | Space ID. Not a secret — also used to validate live-preview requests. |
token |
string |
— | API token. Secret — see below. |
version |
'draft' |
published | Fetch the latest draft instead of published content. |
debug |
boolean |
false |
Client debug logging; also turns on the Visual Editor sync script's snackbars and console logs. |
cacheTTL |
number | false |
300 |
Client response cache TTL in seconds; false disables caching. |
timeoutMs |
number | false |
15000 |
Per-attempt request timeout in ms; false disables it. |
retry |
LocalessRetryOptions | false |
{ attempts: 3, baseDelayMs: 300, maxDelayMs: 5000 } |
Retry policy for failed requests; false disables retrying. |
fetchInit |
LocalessFetchInit |
— | Merged into every fetch call; bypasses the client cache. (fetch and cache are not accepted — they can't survive the options' JSON.stringify into virtual:localess-init.) |
componentsDir |
string |
'src' |
Directory scanned for schema components (<componentsDir>/**/*.astro). |
componentNaming |
ComponentNamingStrategy |
'exact' |
How registry keys and _schema are normalized before matching. Strategy names only, no custom function. |
components |
schema key → component path (relative to componentsDir) |
— | Explicit map merged with auto-discovery. See "Component registry". |
enableFallbackComponent |
boolean |
false |
Render a fallback component for unknown schema keys instead of throwing. |
customFallbackComponent |
string |
built-in FallbackComponent.astro |
Path (relative to componentsDir) to your own fallback component. |
enableSync |
boolean |
false |
Reload-on-save Visual Editor sync. Ignored when livePreview is true. |
livePreview |
boolean |
false |
SSR-only live-patching Visual Editor sync. Requires output: 'server'. |
token is secret-only. @localess/astro has not been reworked for Localess's public tokens (ADR 001) — never pass a token into client-side code or suggest a browser-side client for this package. The integration keeps the token server-side: the LocalessClient is built in a virtual:localess-init module that is injected only via Astro's page-ssr script stage, and the token is never written into virtual:localess-options (which .astro frontmatter and the live-preview middleware read). Only origin and spaceId reach browser-side scripts (for loadLocalessSync(origin) and window.__localessSpaceId). Under static output, the token is only read at build time.
---
import { getLocalessClient } from '@localess/astro';
import LocalessDocument from '@localess/astro/LocalessDocument.astro';
const content = await getLocalessClient().getContentBySlug('home');
---
<LocalessDocument document={content} />getLocalessClient() returns the LocalessClient the integration built from your astro.config.mjs options (it throws if the integration isn't configured). LocalessDocument takes a single prop, document: Content, and throws if document.data is missing; it renders document.data through LocalessComponent, forwarding document.links, document.references, and document.assets.
To render a nested block directly (e.g. inside a custom component), use LocalessComponent:
---
import LocalessComponent from '@localess/astro/LocalessComponent.astro';
---
{data.body.map(item => <LocalessComponent data={item} links={links} references={references} assets={assets} />)}LocalessComponent's props are LocalessComponentProps: data: ContentData (required — throws if missing), optional links: Links, references: References, assets: Assets, plus any extra props. It resolves the component registered for data._schema, normalized through the componentNaming strategy (falling back to the fallback component when enabled, otherwise throwing) and renders it with data/links/references/assets, the localessEditable(data) attributes (data-ll-id, data-ll-schema), and the extra props spread onto it.
Components auto-register from <componentsDir>/**/*.astro (default componentsDir: 'src', so src/**/*.astro), keyed by file name — under the default componentNaming: 'exact', HeroSection.astro registers as HeroSection. Merge in an explicit map via the components option; values are component paths relative to componentsDir (the .astro extension is optional):
localess({
// ...
componentsDir: 'src/components/localess',
components: { 'hero-section': 'sections/Hero' }, // resolves src/components/localess/sections/Hero.astro
});A mapped path that doesn't resolve throws at build time, unless enableFallbackComponent is true, in which case the entry is skipped. Both the registry key and _schema are compared through the componentNaming strategy — under the default 'exact' they must match verbatim; set componentNaming: 'camelCase' (or another strategy) so a file named HeroSection.astro matches _schema: 'hero-section'.
Type a registered component's Props with LocalessSchemaProps<T> — the same generic shape @localess/react's LocalessSchemaProps<T> uses (LocalessComponentProps is the built-in renderer's own props type, not the schema-component contract):
---
import { localessEditable, localessEditableField } from '@localess/astro';
import type { LocalessSchemaProps } from '@localess/astro';
import type { HeroSection } from './.localess/localess'; // your generated content type
export type Props = LocalessSchemaProps<HeroSection>;
const { data } = Astro.props;
---
<section {...localessEditable(data)}>
<h1 {...localessEditableField<HeroSection>('title')}>{data.title}</h1>
</section>localessEditable(data) emits data-ll-id/data-ll-schema; localessEditableField(name) emits data-ll-field. Both are harmless when sync is off. LocalessComponent already spreads localessEditable(data) onto your component, so you only need it yourself when your root element doesn't receive those props.
localess({
// ...
enableFallbackComponent: true,
customFallbackComponent: 'localess/CustomFallback', // optional, relative to componentsDir; omit to use the built-in FallbackComponent.astro
});The built-in FallbackComponent.astro (also importable from @localess/astro/FallbackComponent.astro) accepts LocalessSchemaProps and renders a "Component could not be found for schema …" notice. A custom fallback must accept the same props; if its path doesn't resolve the build throws.
Two tiers, mutually exclusive (livePreview wins when both are set):
enableSync: true— loads the Localess sync script fromorigin(click-to-select, hover outlines), then onsave/publish/unpublishdebounces (~500ms) and reloads the page. It deliberately ignoresinput/change: the editor sends achangeon every connect, so reloading on it loops forever, and a reload cannot show unsaved edits anyway — uselivePreviewfor that. Works under both SSR and static output, but under static output a reload only shows new content inastro dev; a production static build keeps serving its prerendered HTML, so there it provides click-to-select only.livePreview: true— SSR-only (output: 'server'; the integration throws at config-setup time otherwise).save/publish/unpublishreload;input/changedebounce (~500ms), POST the updated content to the current page, andmorphdom-patch the response into the live DOM, keyed bydata-ll-id(preserving the sync script's snackbar container and the editor'sll-hover-highlightclass, which the server HTML knows nothing about). The integration wires this up for you: it injects a page script that callshandleLocalessMessageand registerslive-preview/middleware.tsas@localess/astro/middleware(order: 'pre'), which accepts a preview POST only when it is same-origin (Sec-Fetch-Site: same-origin) and its body'sspaceIdmatches the one passed tolocaless().
With livePreview, fetch the content in page frontmatter, then overlay the draft from getLivePayload(Astro, documentId). The preview POST body carries the edited documentId, and getLivePayload returns the draft only when it matches the id you pass — so pass the rendered document's id, and a page rendering several documents (or one reached by clicking a link inside the preview) won't pick up another document's edits. The payload carries only data (no links/references/assets), and is empty on ordinary requests:
---
import { getLivePayload, getLocalessClient } from '@localess/astro';
import LocalessComponent from '@localess/astro/LocalessComponent.astro';
const content = await getLocalessClient().getContentBySlug('home');
const preview = await getLivePayload(Astro, content.id);
const data = preview.data ?? content.data;
---
<LocalessComponent data={data} links={content.links} references={content.references} assets={content.assets} />The integration always registers a "Localess" Astro dev-toolbar app (src/dev-toolbar/toolbar-app.ts, entrypoint @localess/astro/toolbarApp) with links to the Localess docs and the issue tracker. It has no configuration.
---
import LocalessRichText from '@localess/astro/LocalessRichText.astro';
---
<LocalessRichText content={data.body} />Props: content: LocalessRichTextInput (a rich text document, node, or node array — ContentRichText values fit), and optional renderers. Built on @localess/richtext (see docs/richtext.md) — no TipTap at runtime. Supported elements: headings 1–6, paragraphs, bold/italic/strike/underline/code, ordered/unordered lists, code blocks, blockquotes, horizontal rules, links. Per-node customization via the string-based renderers prop; each renderer receives the node (with attrs) plus pre-rendered children HTML:
<LocalessRichText content={data.body} renderers={{ paragraph: ({ children }) => `<p class="prose">${children}</p>` }} />renderRichTextToHtml(content, { renderers? }) and its alias renderLocalessRichTextToHtml remain available from @localess/astro for standalone rendering. Link hrefs pass a protocol allowlist (javascript:/data: become empty); unknown node types are skipped with a warning (silenced when NODE_ENV === 'production') unless a renderer for that type is provided. A custom link renderer receives an already-sanitized attrs.href, but a string renderer must still escape what it interpolates: use escapeAttr for attribute values and escapeHtml for text (both re-exported, with sanitizeUrl).
---
import { resolveAsset } from '@localess/astro';
---
<img src={resolveAsset(data.heroImage, { w: 800 })} alt={data.title} />resolveAsset(asset: ContentAsset, params?: AssetTransformParams) delegates to the client's assetLink. A ContentAsset is { kind: 'ASSET', uri } — it carries no alt text; take that from another field. Transform params include w, h, q, f, fit, thumbnail — fit controls the w+h behaviour, where the API default cover crops and inside shrinks to fit (see docs/client.md). For the uploaded bytes use resolveAssetOriginal(asset) (inline) or resolveAssetDownload(asset) (attachment); neither takes params. resolveAsset(asset) with no params does not return the uploaded file — a still raster is re-encoded at its format default quality. download and f: 'original' were removed in v4.
LocalessComponent, LocalessDocument, LocalessRichText, FallbackComponent are .astro files and cannot be imported from @localess/astro's default entry point. Import them (default export) from their dedicated subpaths:
import LocalessComponent from '@localess/astro/LocalessComponent.astro';
import LocalessDocument from '@localess/astro/LocalessDocument.astro';
import LocalessRichText from '@localess/astro/LocalessRichText.astro';
import FallbackComponent from '@localess/astro/FallbackComponent.astro';Everything else imports from the default entry point (@localess/astro) — never from @localess/client or @localess/richtext directly:
- Integration and helpers:
localess/localessIntegration,getLocalessClient,getLivePayload,resolveAsset,resolveAssetOriginal,resolveAssetDownload,handleLocalessMessage,normalizeComponentKey,toCamelCase(deprecated),renderRichTextToHtml/renderLocalessRichTextToHtml, and the string-renderer helpersescapeHtml/escapeAttr/sanitizeUrl. - Browser-safe sync utilities:
loadLocalessSync,localessEditable,localessEditableField,isBrowser,isIframe. - Errors:
LocalessApiError. localessClient— server-only factory, re-exported for the integration's generatedvirtual:localess-initmodule; in app code usegetLocalessClient()instead.- Types:
LocalessOptions,LocalessComponentProps,LocalessSchemaProps,LocalessClient,LocalessClientOptions,ComponentNamingStrategy,LocalessSync,EventToApp,EventToAppOf,EventToAppType,EventCallback; model typesContent,ContentData,ContentDataSchema,ContentDataField,ContentMetadata,ContentAsset,ContentLink,ContentReference,ContentRichText,Assets,AssetMetadata,AssetTransformParams,Links,References; rich text typesLocalessRichTextInput,LocalessRichTextDocument,LocalessRichTextNode,LocalessRichTextMark.
The @localess/astro/middleware and @localess/astro/toolbarApp subpaths exist for the integration's own addMiddleware/addDevToolbarApp entrypoints — you don't import them yourself.
getContentBySlug/getContentById throw LocalessApiError on a non-2xx API response — check error.status === 404 to distinguish a missing slug from other failures (network errors, 5xx) and render your own not-found page:
---
import { getLocalessClient, LocalessApiError } from '@localess/astro';
let content;
try {
content = await getLocalessClient().getContentBySlug(slug);
} catch (error) {
if (error instanceof LocalessApiError && error.status === 404) {
Astro.response.status = 404;
return Astro.rewrite('/404');
}
throw error;
}
---Use Astro's experimental_AstroContainer (astro/container) — see packages/astro/CONTRIBUTING.md. Note: LocalessComponent.astro/LocalessDocument.astro themselves aren't unit-testable this way (they depend on virtual:* modules only resolvable inside a real Astro build) — verify changes to them manually against playgrounds/astro/playgrounds/astro-static.
A Localess schema can be named anything; every framework has its own file-naming convention. The
componentNaming option reconciles them by normalizing both the registry key and the incoming
data._schema before they are compared.
| Strategy | HeroBanner / hero-banner / hero_banner -> |
|---|---|
exact (default) |
unchanged — matches only an identical spelling |
camelCase |
heroBanner |
PascalCase |
HeroBanner |
kebab-case |
hero-banner |
snake_case |
hero_banner |
lowercase |
herobanner — separators dropped entirely |
Every strategy except exact is case- and separator-insensitive, so they differ only in the shape of
the key they produce, not in what they match.
Name your component files after your schemas and the default exact works with no configuration.
Reach for another strategy when the two conventions genuinely differ — e.g. schemas named
hero-banner and files named HeroBanner.
Under any strategy other than exact, two files that normalize to the same key (HeroBanner and
hero-banner in one directory) collide; the SDK logs a warning naming both and keeps the first.
See ADR 012.
Changed in v4: the default was previously camelCase — Astro always matched case- and
separator-insensitively. It is now exact. If your .astro filenames differ in case from your
schema names, set componentNaming: 'camelCase' to restore the previous behaviour. toCamelCase()
is still exported but deprecated.
Astro serializes its integration options into a virtual module with JSON.stringify, so
componentNaming accepts the strategy names only, not a custom function.