vite-plugin-builder-io-start
v0.1.4
Published
Wraps TanStack Start's Vite plugin with Builder.io CMS-driven prerendering and spec-compliant sitemap generation.
Maintainers
Readme
vite-plugin-builder-io-start
A thin composition layer over TanStack Start's Vite plugin that drives prerendering from live Builder.io content and emits a spec-compliant sitemap.xml. It queries the Builder Content API for every published page, feeds those paths into tanstackStart's prerender.pages config, and writes the sitemap into the client build output. It is not a fork of tanstackStart — it wraps it, forwards your options through, and owns exactly two of them.
Why it exists. Two reasons. First, TanStack Start's built-in sitemap declares the https://www.sitemaps.org/schemas/sitemap/0.9 namespace URI. Namespace URIs are opaque identifiers compared by exact string equality, not URLs that get fetched — so the https:// variant is a different namespace from the one the sitemap protocol defines, and strict validators reject it. This package emits the correct http:// form. Second, a hard-coded page list drifts from the CMS the moment someone publishes a page; coupling prerendering to the live Builder content means a new page is crawlable on the next build without a code change.
Install
It only runs at build time, so it belongs in devDependencies:
npm i -D vite-plugin-builder-io-start
# or
pnpm add -D vite-plugin-builder-io-startvite (>=7) and @tanstack/react-start are peer dependencies and are not
bundled. A peer dependency means you must have it installed — it says nothing
about which section it belongs in, and peers are satisfied from either. Leave
them where your app already has them. A TanStack Start app keeps
@tanstack/react-start in dependencies, because application code imports it at
runtime; only vite-plugin-builder-io-start itself is a devDependency:
{
"dependencies": {
"@tanstack/react-start": "^1.168.0" // runtime — unchanged
},
"devDependencies": {
"vite": "^8.1.5", // build-time — unchanged
"vite-plugin-builder-io-start": "^0.1.3" // build-time
}
}Usage
// vite.config.ts
import { builderIOStart } from 'vite-plugin-builder-io-start'
import { defineConfig } from 'vite'
export default defineConfig(({ command }) => ({
plugins: [
builderIOStart({
apiKey: process.env.BUILDER_API_KEY!,
command,
host: 'https://example.com',
}),
],
}))builderIOStart is async — the Builder fetch has to resolve before tanstackStart() is constructed, because its pages config is read at plugin-creation time rather than lazily. You do not need to await it yourself: Vite's plugins array has accepted promise entries since Vite 3 and awaits and flattens them during config resolution. Dropping the call straight into the array, as above, is the intended usage.
If you already have other async setup in your config, the awaited-and-spread form works too:
export default defineConfig(async ({ command }) => ({
plugins: [
...(await builderIOStart({
apiKey: process.env.BUILDER_API_KEY!,
command,
host: 'https://example.com',
})),
],
}))A fuller example
builderIOStart({
apiKey: process.env.BUILDER_API_KEY!,
command,
host: 'https://example.com',
model: 'landing-page',
priorities: { '/': 1.0, '/pricing': 0.8 },
defaultPriority: 0.5,
underConstructionPaths: new Set(['/careers']),
prerender: { concurrency: 4, retryCount: 2 },
tanstackStartOptions: {
srcDirectory: 'app',
},
})Options
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| apiKey | string | — | Required. Builder.io Public API key (read-only). Validated unconditionally, including on serve. Never pass a Private API Key — see API keys. |
| command | 'build' \| 'serve' | — | Required. Vite's resolved command. Pass through from defineConfig(({ command }) => …). |
| host | string | — | Required. Absolute origin used to build sitemap <loc> entries, e.g. https://example.com. A trailing slash is stripped. |
| apiBaseUrl | string | https://cdn.builder.io | Origin of the Builder Content API. For pointing integration tests at a stand-in server. apiKey is sent to whatever origin this names, so only point it at a server you control. |
| model | string | 'page' | Builder.io model name to query for pages. |
| priorities | Record<string, number> | {} | Per-path sitemap priority overrides. Unlisted paths fall back to defaultPriority. |
| defaultPriority | number | 0.5 | Default sitemap priority for paths not listed in priorities. |
| underConstructionPaths | Set<string> | new Set() | Paths to fetch from Builder but exclude from prerendering and the sitemap. |
| prerender | { concurrency?, retryCount?, failOnError? } | { failOnError: true } | Forwarded to tanstackStart's prerender config. |
| notFound | false \| { path?, outputPath? } | { path: '/404', outputPath: '404.html' } | Prerendered not-found document, written to the output root because that is what static hosts serve for unknown paths. Deliberately excluded from the sitemap. false emits none. |
| changefreq | ChangeFreq | — | Written for every sitemap entry when set. A hint crawlers largely disregard. |
| robots | { enabled?, disallow? } | { enabled: false } | Emit a robots.txt pointing at the generated sitemap. See robots.txt. |
| tanstackStartOptions | Omit<TanStackStartOptions, 'prerender' \| 'pages'> | {} | Additional options merged into the config passed to tanstackStart(). Typed against the plugin's real config type, so you get autocomplete and typo rejection. prerender and pages are computed internally and omitted here so there is exactly one place to set them. |
robots.txt
A sitemap nothing points at is only half the job — crawlers discover sitemaps
through the Sitemap: directive in robots.txt. Opt in:
robots: { enabled: true, disallow: ['/admin'] }which writes, alongside sitemap.xml:
User-agent: *
Allow: /
Disallow: /admin
Sitemap: https://example.com/sitemap.xmlIt is opt-in, not automatic. Many apps already ship a hand-written
public/robots.txt, which Vite copies into the build output. Enabling this
without noticing would discard crawler rules someone cared enough to write, so
if a robots.txt is already present the build fails rather than overwriting it.
underConstructionPaths are not added to disallow automatically. It looks
like the obvious default, but robots.txt is world-readable, so listing a path
there announces that it exists — a URL nobody has linked becomes trivially
discoverable. Add them yourself if being crawled is the bigger concern than
being found.
API keys
Pass your Public API Key — the read-only one, found in Space Settings or via the command palette. Builder documents it as safe to expose, so it needs no secret handling and can be committed if that suits your setup.
Do not pass a Private API Key. It grants write access to your Space, and this package sends the key as a query parameter, where it is captured by CI logs, proxy logs, and shell history. A Private Key would otherwise appear to work, since it can read as well — which is what makes the mistake easy to miss.
This is enforced, not just documented: a key with the bpk- private-key prefix
throws before any request is made, including on serve where no fetch happens
at all. Public keys are bare hex and cannot carry that prefix, so a valid key is
never rejected.
Behaviour notes
Dev skips the fetch, not the validation. On command === 'serve' there is no prerendering, so the Builder round trip is skipped and an empty page list is used. The apiKey check still runs — a missing key fails loudly at dev-server start rather than surfacing days later as a mysteriously empty sitemap.
Failures throw; they never degrade to partial results. A non-OK Builder response or a zero-result query throws. Shipping a build that quietly dropped pages from the crawlable surface is the kind of regression nobody notices until organic traffic has already fallen off, so this package refuses to produce one.
Builder paths are normalised before use. data.url is set by whoever edits
your CMS, and it feeds two things that must agree: the path Start prerenders and
the <loc> written to the sitemap. Start resolves .. segments before writing
files, so an unnormalised path would advertise a URL the build never produced.
Paths are canonicalised (traversal segments resolved, leading slash enforced,
duplicate slashes collapsed) with query, hash, and trailing slash preserved. A
value that cannot be a path on your host — an absolute or protocol-relative URL —
throws rather than being coerced into a silently wrong entry.
Pagination runs to completion. The Builder Content API caps results at 100 per request. fetchPrerenderPages pages through with offset until a short page comes back, so sites over 100 pages are not silently truncated.
Only the client environment writes the sitemap. A Start build runs writeBundle once per environment (client, ssr, …), each with its own outDir. Writing from any other environment would emit a stray sitemap into the server bundle or race the client write.
No retry/backoff on the Builder fetch. Retries are a prerender concern already covered by tanstackStart's own retryCount.
Also exported
builderIOStart is the main entry point, but the pieces are exported individually if you need them:
import { fetchPrerenderPages, sitemapPlugin } from 'vite-plugin-builder-io-start'
import type { BuilderIOStartOptions, PrerenderPage } from 'vite-plugin-builder-io-start'Scope
Deliberately narrow for a first version: no CLI, and no framework adapters beyond @tanstack/react-start.
License
MIT
