@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()],
});
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,
});
Three 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.
- Type names are derived from slugs:
case-studiesbecomesCaseStudiesEntry. A slug starting with a digit is prefixed (Stet…Entry) so the identifier stays legal.
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[]