The @localess/react package is the official React integration for the Localess headless CMS platform. In a React Router v7 framework-mode app you wire it up through @localess/react/vite, which generates the localessInit() call for the SSR graph and the browser bundle alike — so the same setup serves both server-rendered and prerendered (prerender) output.
⚠️ Security Notice:@localess/react/vite ships token to the browser bundle as well as the SSR graph — there is currently no secret/public split for this plugin. Treat it as a public, read-only value.
For Vite-based SSR frameworks (TanStack Start, React Router v7 framework mode), @localess/react/vite replaces a manual localessInit() call with one Vite plugin, generating the same localessInit() call identically for every build graph (SSR and browser):
// vite.config.tsimport { defineConfig } from 'vite';import { localess } from '@localess/react/vite';export default defineConfig({ plugins: [ localess({ origin: process.env.LOCALESS_ORIGIN!, spaceId: process.env.LOCALESS_SPACE_ID!, token: process.env.LOCALESS_TOKEN!, enableSync: true, // Components are auto-discovered from this directory — no manual registry. componentsDir: 'app/components/localess', // default: 'src' // Files are lowercase (page.tsx) while the schemas are PascalCase (Page), // so a case-insensitive naming strategy reconciles the two. componentNaming: 'camelCase', // default: 'exact' components: { 'HeroSection': './HeroOverride.tsx#HeroOverride' }, // optional, overrides auto-discovery }), ],});
Every .tsx/.jsx file under componentsDir is auto-registered under its filename verbatim — page.tsx registers as page — and that key is matched against data._schema. Each file must default-export its component, as the playgrounds do — a file with only named exports registers the module object instead.
Because React filenames and CMS schema names rarely share a convention, set componentNaming to normalize both sides before they're compared:
Strategy
HeroBanner / hero-banner / hero_banner resolve as
exact(default)
unchanged — only an identical spelling matches
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 key shape they produce, not in what they match. Both playgrounds use camelCase for exactly the reason in the comment above: lowercase files, PascalCase schemas.
componentNaming applies to components overrides too — their keys are normalized with the same strategy. An override maps a key to a file path relative to componentsDir; a bare path assumes a default export, suffix it with #ExportName to import a named export instead.
TypeScript: if your tsconfig sets noUncheckedSideEffectImports: true, the bare import 'virtual:localess-init' below fails to resolve under tsc unless you add the plugin's ambient module declarations to types:
Then import the virtual module once, in app/root.tsx. The bare import 'virtual:localess-init' is what runs the generated localessInit() call, and the root module loads before any route's loader runs:
// app/root.tsximport 'virtual:localess-init';
After that, getLocalessClient() works in any loader — see Visual Editor for a complete route.
Known gap: unlike every other export, token here is shipped to the browser bundle as well as the SSR graph — there is currently no secret/public token split for this plugin. Treat token as a public value when using localess() from @localess/react/vite, until a scoped/public-token mechanism replaces this.
Components receive data, links, and references as props — type them with LocalessSchemaProps<T>, the contract every registered component accepts (there's no base class to extend, unlike @localess/angular's SchemaComponent<T>). Always spread localessEditable/localessEditableField so the Visual Editor can highlight and select the block and its fields:
import { localessEditable, localessEditableField } from "@localess/react";import type { LocalessSchemaProps } from "@localess/react";import type { HeroSection } from "./.localess/localess";const Hero = ({ data, links, references }: LocalessSchemaProps<HeroSection>) => ( <section {...localessEditable(data)}> <h1 {...localessEditableField<HeroSection>('title')}>{data.title}</h1> <p {...localessEditableField<HeroSection>('subtitle')}>{data.subtitle}</p> </section>);
Pass links and references through the entire tree — child LocalessComponents need them.
A catch-all route has no fixed list of paths, so a static build has to ask Localess for them. Build configuration runs before the app exists, so getLocalessClient() isn't available there — create a standalone client with localessClient from @localess/react/ssr instead. This code only runs in the Node build, so its token never ships to the browser.
Render prerendered routes with LocalessServerDocument, importing from @localess/react/ssr:
// app/routes/catch-all.tsximport { getLocalessClient, LocalessServerDocument } from "@localess/react/ssr";import type { Page } from "~/shared/models/localess";import type { Route } from "./+types/catch-all";export async function loader({ params }: Route.LoaderArgs) { const document = await getLocalessClient().getContentBySlug<Page>(params["*"] || "home"); return { document };}export default function CatchAllPage({ loaderData }: Route.ComponentProps) { return <LocalessServerDocument document={loaderData.document} />;}
The static playground leaves enableSync off: prerendered HTML has no server to apply live edits, so point the Visual Editor at an SSR deployment instead. For locale-prefixed paths, see playgrounds/react-router-static.
This package ships a SKILL.md file that provides AI coding agents (GitHub Copilot, Claude Code, Cursor, and others) with accurate, up-to-date APIs, patterns, and best practices.