@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:
origin?string
The Stet deployment to generate from. Falls back to the config file, then STET_ORIGIN, then the hosted cloud.
stringapiKey?string
Key used to fetch the model at codegen time. Falls back to STET_API_KEY. Never written into the generated file.
stringoutput?string
Where the generated module goes, relative to the project root.
stringsrc/stet.gen.tswatch?boolean
Regenerate while the dev server runs. Never affects builds.
booleantrueconfig?string
Path to stet.config.ts. Auto-detected when omitted.
stringWhat 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-studiesbecomesCaseStudiesEntry. 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');
fetchContentModel(origin, apiKey)?Promise<ContentModel>
Fetches the content model the given key's organization has shaped. Throws on a non-2xx response.
Promise<ContentModel>renderContentModule(model, origin)?string
Renders the generated module. Pure: no filesystem, no network.
stringentryTypeName(slug)?string
The type name for a slug. "case-studies" becomes "CaseStudiesEntry".
stringContentModel?type
The /api/v1/model payload: types, each with a slug, kind and fields.
typeConfig 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:
findStetConfig(root)?string | undefined
Finds a config by trying CONFIG_CANDIDATES under root.
string | undefinedloadStetConfig(path)?Promise<StetConfig | undefined>
Loads a config with jiti and returns its defineStet() result.
Promise<StetConfig | undefined>trackingPlanEvents(config)?EventsShape | undefined
The plan's events, if the config declares any.
EventsShape | undefinedCONFIG_CANDIDATES?string[]
The paths tried when none is given.
string[]