Lessons
What it actually took to wire SvelteKit 3 + Vite + Cloudflare together. Useful if you're extending this — human or agent.
The first groups follow the principles in craftz's Practice, in its order, and each says what holds its principle here. base's own groups come after them, by the tool each is about.
Measure before changing
The rule is Measure before changing.
- TypeScript 7 checks types beside 6. Installed as
@typescript/native@npm:typescript@7, it leavestypescripton 6 for SvelteKit, andsvelte-check --tsgochecks with it. That halvessvelte-check, from about 7 seconds to 3.5, with 7.0.2 or 7.1.0-beta, and still catches a type error in a.sveltefile and a.tsfile. TypeScript 7 won't take a path intypes, soworker-configuration.d.tsgoes ininclude.--tsgois new in svelte-check 4.7, so a diagnostic it misses is a reason to go back. .tsfiles are type-checked twice, and that's fine. oxlint's type check andsvelte-checkboth read them.svelte-check --no-tsconfigchecks only.sveltefiles, but it takes 6.4 seconds, no faster than a full check on TypeScript 6, and--tsgowon't run without a tsconfig.- Not
--tsgo-experimental-api. With 7.0.2 it's no faster than--tsgohere, since most of the time is Node starting and Svelte files converting. With 7.1.0-beta,svelte-check4.7.7 crashes on it with "Invalid filesystem callback 'directoryExists'", so one TypeScript minor broke it.
Don't repeat yourself
The rule is Don't repeat yourself. The toolchain and scripts are craftz's, and each page's order is docs/README.md's nav.
- Back on Vite+, for the shared toolchain. base left Vite+ in #25: the vite override and the switched-off peer checks cost more than one config saved, for one repository. It came back because every repository shares one toolchain, set at craftz, so the cost is paid once and each Vite+ release moves everywhere together. The override stays, and pnpm doesn't check
vitepeer ranges. package.jsonis the one place.devEngines.packageManagerusesonFail: download, so pnpm fetches and runs the pinned version whatever is installed globally, and a pnpm release never breaks a checkout.devEngines.runtimekeepsonFail: error, so a wrong Node fails loudly.enginesandpackageManagerare what CI reads (actions/setup-nodewithnode-version-file: package.json, andpnpm/action-setup). mise reads the same file whenidiomatic_version_file_enable_toolsis on, so there's nomise.tomlor.node-versionto keep in sync.- Each page's metadata is a
---block —titleandsummary— read by markz and validated by a Zod schema. The slug is the filename, and the order isdocs/README.md'snav, which GitHub and prose read too. Adding a page is adding one file, and a missing or mistyped key fails the build naming the file. Extend the schema for more fields. - One parser replaces
marked,yamland the glue. markz also gives heading ids (unique per page, any script), which used to be a custom renderer and awalkTokenspass. Since markz 0.4.0 it keeps quotes and dashes as typed, so type the character you want.
Build only what gets used
The rule is Build only what gets used.
- The whole site is static, so
src/routes/+layout.tsdoesexport const prerender = trueandexport const csr = false. Without the second, every prerendered page still ships the hydration runtime for no benefit. A route with real client behaviour setscsr = truein its own+page.ts. - Chose
import.meta.glob('/docs/*.md', { query: '?raw', eager: true })+@amitkaps/markzover Content Collections: no config file, no codegen step, no sync ordering. It all lives insrc/lib/docs.ts. - Rendering happens at build time (pages are prerendered), so
@amitkaps/markzandzodnever reach the client or the Worker — they aredevDependencies.
Have a way to verify
The rule is Have a way to verify.
- Prerendering is strict.
handleHttpErrorinvite.config.tsthrows, so a broken internal link fails the build — a link checker for free. If a path is served by something other than this app, add it to the allowlist there rather than loosening the handler.
Silence is the failure
The rule is Silence is the failure.
- Any markz warning fails the build. It keeps unsupported syntax (raw HTML,
*emphasis*,__strong__, reference links, bare URLs, ...) as literal text and warns, sorenderinsrc/lib/docs.tsthrows with the file, line and the form to write instead. The dialect is in markz's syntax doc. - Empty metadata values arrive as
null. A key written with nothing after it (image:) reads asnull, and Zod's.optional()rejectsnull.src/lib/docs.tsdrops null keys before validating.
SvelteKit 3
- No
svelte.config.js. Adapter and compiler options are passed inline tosveltekit()invite.config.ts. - That config is flat. Options such as
filesorprerendergo straight intosveltekit({ ... }). Wrapping them in akitkey, as in SvelteKit 2, fails with "configuration no longer lives inside akitnamespace". $libis gone — it's#lib, apackage.jsonimportssubpath. An extensionless#lib/thingdoesn't resolve inside.sveltefiles, so re-export everything through asrc/lib/index.tsbarrel and import#lib. Components and assets (which have extensions) can be imported directly:#lib/components/X.svelte.$app/tsconfigis virtual —svelte-kit syncwrites it tonode_modules/$app/tsconfig.json. Anything that readstsconfig.jsonoutside the Vite pipeline (vp check's type check,svelte-check) needs asvelte-kit syncfirst, hence thesvelte-kit syncat the front of thecheckscript.- No Vitest guard needed on
adapter-cloudflare8.0.0+. Earlier versions started wrangler'sgetPlatformProxy()from the ViteconfigureServerhook and never disposed it, so tests passed and Vitest then hung 10s printing "close timed out … something prevents 2 Vite servers from exiting" (sveltejs/kit#17215). If you see that on an older adapter, skip the plugins underprocess.env.VITEST. - The Svelte VS Code extension can't read the config yet. With no
svelte.config.js, it looks for the Svelte plugin invite.config.tsand fails with "No Svelte configuration found in vite config" on line 1 of every.sveltefile, and<script>blocks lose their highlighting. Nothing is wrong with the code:svelte-check(run bypnpm check) is the source of truth. Re-test after the extension updates.
Toolchain
checkwrapsvp check.svelte-kit syncandwrangler typescome first, sincevp check's type check reads the generated$app/tsconfigandworker-configuration.d.ts.svelte-checkcomes after, since oxlint doesn't type-check.sveltefiles.fixis plainvp check --fix, as everywhere.- Oxfmt formats
.sveltefrom thefmtblock. An emptysvelte: {}turns it on. Under Vite+ there's no.oxfmtrc.json. - Tests import from
vite-plus/test, notvitest, which isn't a direct dependency. - Moving between toolchains needs a fresh lockfile. A lockfile that resolved one keeps optional peers of the other. Delete
pnpm-lock.yamlandnode_modules, then install. - An empty test suite fails.
vitest runexits 1 when it finds no test files, so a fork that removes the tests breaks CI until it adds one back. SetpassWithNoTestswhile it has none. - Settings moved from
.npmrc/package.json#pnpmtopnpm-workspace.yaml. onlyBuiltDependenciesis now anallowBuilds:map (esbuild: true, …).minimumReleaseAgeblocks packages published in the last N minutes — a supply-chain guard, configured insetup.md§5.- markz's metadata is a flat
key: valueblock, not full YAML: no nesting, lists only as[a, b], and a value YAML would read differently (no, a bare date) warns rather than guessing. Quote it.
TypeScript
- Stay on TypeScript 6 for now. TypeScript 7, the native port, removes the JS API SvelteKit uses to load
tsconfig.json. So everysvelte-kit syncfails with "Cannot read properties of undefined (reading 'readFile')". Last tried with SvelteKit 3.1.0 and TypeScript 7.1.0-beta, and 7.0.2 fails the same way. Kit's peer range is^6, andsvelte-check4.7 allows^5 || ^6. Move when both widen their peers. --tsgokeeps deleted components. It writes a.tscopy of each.sveltefile into.svelte-kit/.svelte-check/and never removes one, andsvelte-kit syncdoesn't either. So after a component is deleted or renamed, its old errors still failpnpm check.checkremoves the folder beforesvelte-check, which costs nothing, since a run from empty takes the same 3.5 seconds. CI starts empty anyway.
Cloudflare
@sveltejs/adapter-cloudflarewrites.svelte-kit/cloudflare, andmainandassets.directoryinwrangler.tomlpoint there.worker-configuration.d.tsis generated bywrangler types, not committed — theprepareandcheckscripts both regenerate it, so editingwrangler.tomlneeds no separate step.- Once a build exists, that generated file points
Cloudflare.GlobalPropsat.svelte-kit/cloudflare/_worker.js, dragging the built worker into the TypeScript program."checkJs": falseintsconfig.jsonkeepspnpm checkfrom reporting errors in generated output. - Stay on
wrangler—cfcan't deploy SvelteKit yet. Last tried withcf1.0.0-beta.12, Cloudflare's successor CLI, after the switch to plain Vite.cf migrateturnswrangler.tomlinto a typedcloudflare.config.ts, andcf workers typesreplaceswrangler types. With the default Wrangler bundler,assets.directorymoves to a separatewrangler.config.ts. With--bundler vite, it has nowhere to go and is left as a TODO. Either way,cf deployfails. Itspnpm vite buildnow succeeds, though it failed while this project used Vite+. Butcf deployuploads only cf's own Build Output, in.cloudflare/output/v0/, andadapter-cloudflaredoesn't write it.--prebuiltfails the same way. For Vite projects that output comes from@cloudflare/vite-plugin, and SvelteKit doesn't build through that plugin. cf's own framework table marks SvelteKitsupportsCf: falseand knows only majors up to 2. Moving just the config would leavewrangler deployreading a second copy of it. Re-test when the adapter orcfsupports this. Wrangler is maintained for 18 months after thecfbeta ends. pnpm/action-setup+actions/setup-node(node-version-file: package.json,cache: pnpm) is all the setup needed. Every step runs throughpnpm run …, so no global tooling in CI either.- CI checks, and Cloudflare deploys. Deploys come from the Git integration (Workers Builds), not a
wrangler-actionstep, so there's no API token or secret to keep. Its dashboard holds two fixed commands,pnpm run verifyandpnpm run ship, andpackage.jsondecides what they run.verifyfails on a bad check, so a red build never deploys, and CI runs the same script. - The deploy script is
ship, notdeploy.pnpm deployis a built-in pnpm command, which copies a workspace package. So adeployscript only runs aspnpm run deploy, and a slip runs the wrong thing.