Localess
TanStack Start

Getting Started

Use Localess in a TanStack Start app, in both server-rendered and prerendered output.

Package: @localess/react

The @localess/react package is the official React integration for the Localess headless CMS platform. In a TanStack Start 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 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.

Requirements

  • Node.js >= 24.0.0
  • React 17, 18, or 19

Installation

# npm
npm install @localess/react@latest

# yarn
yarn add @localess/react@latest

# pnpm
pnpm add @localess/react@latest

Setup

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.ts
import { 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: 'src/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:

StrategyHeroBanner / hero-banner / hero_banner resolve as
exact (default)unchanged — only an identical spelling matches
camelCaseheroBanner
PascalCaseHeroBanner
kebab-casehero-banner
snake_casehero_banner
lowercaseherobanner — 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:

{ "compilerOptions": { "types": ["vite/client", "@localess/react/vite/virtual-modules"] } }

Then import the virtual module in src/routes/__root.tsx and in each server function module that fetches, as the playground does. The bare import 'virtual:localess-init' is what runs the generated localessInit() call, and a server function module can be evaluated on its own:

// src/shared/server/get-page-content.ts
import 'virtual:localess-init';
import { getLocalessClient, LocalessApiError } from '@localess/react';

export async function getPageContent(slug: string, locale?: string) {
  try {
    return await getLocalessClient().getContentBySlug<Page>(slug, { locale });
  } catch (error) {
    if (error instanceof LocalessApiError && error.status === 404) return null;
    throw error;
  }
}

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.

Playgrounds: playgrounds/react-router and playgrounds/tanstack-start are full SSR projects using @localess/react/vite with enableSync. playgrounds/react-router-static and playgrounds/tanstack-start-static mirror them under prerendered/static output.

Writing components

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.

localessEditable(content)

Marks a content block root element as editable.

import { localessEditable } from "@localess/react";

const Header = ({ data }) => (
  <nav {...localessEditable(data)}>
    {/* ... */}
  </nav>
);

localessEditableField<T>(fieldName)

Marks a specific field within a content block as editable, with type-safe field name inference when combined with generated types.

import { localessEditableField } from "@localess/react";

const Hero = ({ data }: { data: HeroBlock }) => (
  <section {...localessEditable(data)}>
    <h1 {...localessEditableField<HeroBlock>('title')}>{data.title}</h1>
    <p {...localessEditableField<HeroBlock>('subtitle')}>{data.subtitle}</p>
  </section>
);

Playground: playgrounds/tanstack-start and playgrounds/tanstack-start-static are the reference implementations for this guide.

Prerendering

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.

// vite.config.ts
import { defineConfig } from "vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { localessClient } from "@localess/react/ssr";
import { localess } from "@localess/react/vite";

async function getPrerenderPaths(): Promise<string[]> {
  const client = localessClient({
    origin: process.env.LOCALESS_ORIGIN!,
    spaceId: process.env.LOCALESS_SPACE_ID!,
    token: process.env.LOCALESS_TOKEN!,
  });
  const links = await client.getLinks({ kind: "DOCUMENT" });
  return ["/", ...Object.values(links).map((link) => `/${link.fullSlug}`)];
}

export default defineConfig(async () => {
  const pages = await getPrerenderPaths();
  return {
    plugins: [
      localess({ /* … as in Setup, without enableSync */ }),
      tanstackStart({
        prerender: { enabled: true, failOnError: true },
        pages: pages.map((path) => ({ path })),
      }),
      viteReact(),
    ],
  };
});

Render prerendered routes with LocalessServerDocument, importing from @localess/react/ssr:

// src/routes/$.tsx
import { createFileRoute } from "@tanstack/react-router";
import { LocalessServerDocument } from "@localess/react/ssr";
import { getPageContent } from "@/shared/server/get-page-content";

export const Route = createFileRoute("/$")({
  loader: async ({ params }) => ({
    document: await getPageContent({ data: { slug: params._splat || "home" } }),
  }),
  component: CatchAllPage,
});

function CatchAllPage() {
  const { document } = Route.useLoaderData();
  return <LocalessServerDocument document={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/tanstack-start-static.

AI Coding Agents

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.

Reference it from your project's AGENTS.md:

## Localess

@node_modules/@localess/react/SKILL.md

License

MIT

On this page