Skip to content
Esc
navigateopen⌘Jpreview
On this page

@stetcms/vite

The Vite plugin that turns your content model into typed TypeScript, the file it writes, and the reason it never breaks your build.

@stetcms/vite turns your organization’s content model into a typed module in your project. The collections and maps your content team shapes become stet.<slug>.list() and .get() calls your editor autocompletes, and a field added in the UI reaches your types moments later without a restart.

It also publishes your analytics tracking plan, so the events your code declares can be charted in Stet before anyone has fired one.

The plugin

import { stet } from '@stetcms/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [stet()],
});

Frameworks that build on Vite need nothing extra: in Astro the same plugin goes through vite.plugins and both jobs run unchanged — see the Astro guide.

Every option overrides the same key in stet.config.ts:

PropType
origin?string

The Stet deployment to generate from. Falls back to the config file, then STET_ORIGIN, then the hosted cloud.

Typestring
apiKey?string

Key used to fetch the model at codegen time. Falls back to STET_API_KEY. Never written into the generated file.

Typestring
output?string

Where the generated module goes, relative to the project root.

Typestring
Defaultsrc/stet.gen.ts
watch?boolean

Regenerate while the dev server runs. Never affects builds.

Typeboolean
Defaulttrue
config?string

Path to stet.config.ts. Auto-detected when omitted.

Typestring

What it does, and when

On build and dev-server start

buildStart fetches /api/v1/model and writes the generated module. If the config declares an analytics plan, it publishes that too.

While the dev server runs

Every three seconds it refetches the model and rewrites the file, but only when the rendered output actually changed — so an unchanged model triggers no reload. Set watch: false to turn this off.

The config file is re-read on every pass rather than cached, so editing it during a watch run takes effect without restarting the dev server.

It never breaks your build

This is a rule, not an implementation detail. Without a key, or with Stet unreachable, the plugin warns and carries on:

Situation What happens
No API key Warns, and writes an empty model only if no file exists yet
API unreachable or erroring Warns, and leaves the last generated file in place
Tracking plan fails to publish Warns; the bundle is produced either way
Transient failure during a watch Silent; the next tick retries

Keeping the previous file is the important half: a network blip during a deploy leaves you building against a slightly stale model, not against nothing.

The generated module

The file is safe to commit. It embeds no key, and reads both the key and the origin from the environment at runtime, so one generated file works in every environment — and a build that could not refresh it cannot pin a deployment to whatever origin the last developer generated from.

// Generated by Stet from your content model. Do not edit: it is
// regenerated by @stetcms/vite or `stet generate`.
import { createContentClient } from '@stetcms/client';
import type { ContentEntryBase, ContentAsset, ContentReference } from '@stetcms/client';

export type PostsEntry = ContentEntryBase & {
  fields: {
    body?: string | null;
    cover?: ContentAsset | null;
    /** Reference into "authors": fetch with stet["authors"].get(slug). */
    author?: ContentReference | null;
    status?: 'draft' | 'published' | null;
  };
};

export type StetContentModel = {
  posts: { kind: 'collection'; entry: PostsEntry };
};

const origin =
  typeof process === 'undefined'
    ? 'https://stetcms.com'
    : (process.env.STET_ORIGIN ?? 'https://stetcms.com');

export const stet = createContentClient<StetContentModel>({
  origin,
  apiKey: typeof process === 'undefined' ? undefined : process.env.STET_API_KEY,
});

Four things are worth knowing about the output:

  • Only used imports are emitted. A model with no asset field imports no ContentAsset, so a strict lint has nothing to complain about.
  • Reference fields carry a doc comment naming the collection they point at. The type is the same shape whatever the target, so the doc comment is where your editor tells you what to pass the slug to.
  • Deleted and renamed keys become deprecations, not removals. See below.
  • Type names are derived from slugs: case-studies becomes CaseStudiesEntry. A slug starting with a digit is prefixed (Stet…Entry) so the identifier stays legal.

Retired keys are deprecated, not dropped

When someone deletes a field in the Stet UI, the key stays in your generated types, marked @deprecated:

export type PostsEntry = ContentEntryBase & {
  fields: {
    body?: string | null;
    /** @deprecated Deleted from the content model on 2026-03-12 by Ada Lovelace; entries still return the last value it held until the Action is completed. */
    subtitle?: string | null;
  };
};

The date and the name come from the deletion itself, so a key you meet months later can be traced to the change that retired it and to someone who can explain it.

Your editor strikes the field through wherever you read it, and your build keeps passing. Dropping the key instead would turn every post.fields.subtitle into a type error moments after someone else made a change — with the watcher running, while you were mid-task. The deprecation says the same thing without stopping you, so you migrate when it suits you.

Entries keep the last value a deprecated field held and go on returning it, so the page reading the key renders as it always did. A type that still compiles over a value that has vanished is only half a promise; this is the other half.

The value goes when someone completes the field migration under Developers → Actions (see Content model). That takes the key out of the model, so the next generated client drops it rather than deprecating it, and the reads that were struck through become type errors. That is the point at which they should be: a developer chose it, knowing what still referred to the key.

A rename follows the same contract. The old key is emitted with a deprecation that points to the current key, and both return the current field value. Rename chains can leave several aliases pointing to one canonical key until each Action is completed.

How fields are typed

Field type TypeScript
number number
checkbox boolean
person { id: string; name: string }
select a union of the option names
multi_select an array of that union
asset ContentAsset
reference ContentReference
multi_reference ContentReference[]
text, rich_text, date, link string

A select with no options yet falls back to string rather than to never, so a half-built model still compiles. Every field is emitted as ?: T | null.

Doing it without Vite

The codegen is a plain pair of functions, exported from @stetcms/client/codegen. The CLI uses them, and so can you:

import { fetchContentModel, renderContentModule } from '@stetcms/client/codegen';

const model = await fetchContentModel('https://stetcms.com', process.env.STET_API_KEY);
const code = renderContentModule(model, 'https://stetcms.com');
PropType
fetchContentModel(origin, apiKey)?Promise<ContentModel>

Fetches the content model the given key's organization has shaped. Throws on a non-2xx response.

TypePromise<ContentModel>
renderContentModule(model, origin)?string

Renders the generated module. Pure: no filesystem, no network.

Typestring
entryTypeName(slug)?string

The type name for a slug. "case-studies" becomes "CaseStudiesEntry".

Typestring
ContentModel?type

The /api/v1/model payload: types, each with a slug, kind and fields.

Typetype

Config loading

@stetcms/vite/config is a Node-only entry point, kept out of the plugin so the CLI can reuse it without pulling in Vite:

PropType
findStetConfig(root)?string | undefined

Finds a config by trying CONFIG_CANDIDATES under root.

Typestring | undefined
loadStetConfig(path)?Promise<StetConfig | undefined>

Loads a config with jiti and returns its defineStet() result.

TypePromise<StetConfig | undefined>
trackingPlanEvents(config)?EventsShape | undefined

The plan's events, if the config declares any.

TypeEventsShape | undefined
CONFIG_CANDIDATES?string[]

The paths tried when none is given.

Typestring[]

Was this page helpful?