The site is done, the client is happy, and a week later they want the hero headline changed. Then the opening hours. Then a photo. Each one is a message to you and a deploy. A separate CMS fixes that at the cost of a second system to build the site against, a content model to design, and an editing screen that looks nothing like the page. vela enable cms does it the other way round: the page is the editor. Editors sign in on the live site, click the text they want to change, and publish.
Prerequisites
A SvelteKit project that runs as a Node server, so the app can host the CMS itself. A vela project with a backend is one: npx vela create my-app, or npx vela enable backend inside an existing SvelteKit project (npx vela bless for the full setup, details). So is any project on @sveltejs/adapter-node, PocketBase or not. A site without a server can use the CMS too, hosted elsewhere; see the end of this tutorial.
Run the command
✔ Installed @velastack/cms, better-sqlite3, marked ✔ Created 4 files, modified 3 src/lib/cms.ts src/lib/server/cms.ts src/routes/api/cms/[...path]/+server.ts src/routes/uploads/[filename]/+server.ts src/routes/+layout.server.ts src/routes/+layout.svelte vite.config.ts
No PocketBase collections: the CMS keeps its content and its editors in its own SQLite database.
What was generated
The backend
The CMS server runs inside your app, mounted at /api/cms. Its database and uploads live in the data directory that vela keeps across deploys:
import { dataPath } from "@velastack/kit/server";
import { createCmsBackend } from "@velastack/cms/backend";
/**
* The CMS HTTP backend, mounted single-tenant at `/api/cms` by
* `src/routes/api/cms/[...path]/+server.ts`.
*
* Same origin as the site, so there is no CORS to configure and the session
* cookie is first-party. Editors sign in against the backend's own
* `cms_editors` table — add one with `vela cms editor add <email>`.
*
* `dataPath` reads `VELA_DATA_DIR`, which `vela` sets wherever it runs the
* app, so the database and uploads live in the data directory that outlives
* a release rather than inside the build.
*/
export const cms = createCmsBackend({
dbPath: dataPath("cms.sqlite"),
uploadDir: dataPath("uploads"),
});
import { cms } from "$lib/server/cms";
// `fallback` catches every method, including OPTIONS. The backend does its own
// sub-routing off `params.path`, so a new endpoint never touches this file.
export const prerender = false;
export const fallback = cms.handler;
Uploaded images are written to that same data directory and served from /uploads/<file>:
import { cms } from "$lib/server/cms";
// Uploads land in the data directory after the build, so no static handler
// would find them. Media URLs written into content are root-relative
// `/uploads/<file>`, which is what this route serves.
export const prerender = false;
export const GET = cms.serveUpload;
The read path
src/lib/cms.ts is the app’s side: where to read content from, and which locales exist. The root server layout calls loadCms so every page gets its content, and a page an editor has deleted answers 404, 410 or a redirect:
import { apiAdapter, createCms } from "@velastack/cms/server";
/**
* The CMS read path, called from the root `+layout.server.ts`.
*
* `/api/cms` is where `src/routes/api/cms/[...path]/+server.ts` mounts the
* backend. It is also the admin bar's default, so the endpoint is named here
* and nowhere else.
*
* `locales` lists every locale the site supports; the first is the default
* that a missing translation falls back to.
*/
export const { load: loadCms, generateEntries } = createCms({
adapter: apiAdapter({ endpoint: "/api/cms" }),
locales: ["en"],
});
import { loadFlash } from "sveltekit-flash-message/server";
import { defineBaseMetaTags } from "svelte-meta-tags";
import { error, redirect } from "@sveltejs/kit";
import { loadCms } from "$lib/cms";
import { site } from "$lib/site";
export const load = loadFlash(async (event) => {
const { url } = event;
// Built from `site.url`, not `url.origin`: every deployment and every
// prerendered page (where the origin is SvelteKit's placeholder host) should
// point at the one address the site is published under.
const canonical = new URL(url.pathname, site.url).href;
const baseTags = defineBaseMetaTags({
title: "",
titleTemplate: `%s | ${site.name}`,
description: "",
canonical,
openGraph: {
type: "website",
url: canonical,
images: [
{
url: `${site.url}/og.jpg`,
alt: site.name,
width: 1200,
height: 630,
},
],
},
});
const { cms, notFound, gone, redirectTo } = await loadCms(event, {
locale: "en",
});
if (redirectTo) redirect(308, redirectTo);
if (gone) error(410, "Gone");
if (notFound) error(404, "Not found");
return {
cms,
...baseTags,
};
});
The admin bar
One component in the root layout. Visitors never see it; editors open a page with ?edit on the URL, or press Ctrl+E, and sign in:
<script lang="ts">
import "../app.css";
import favicon from "$lib/assets/favicon.svg";
import { site } from "$lib/site";
import { ModeWatcher } from "mode-watcher";
import { getFlash } from "sveltekit-flash-message";
import { toast } from "svelte-sonner";
import { page } from "$app/state";
import { Toaster } from "$lib/components/ui/sonner";
import { MetaTags, deepMerge } from "svelte-meta-tags";
import { AdminBar } from "@velastack/cms";
let { data, children } = $props();
const flash = getFlash(page);
$effect(() => {
if (!$flash || $flash.type !== "toast") {
return;
}
toast.message($flash.message);
$flash = undefined;
});
let metaTags = $derived(deepMerge(data.baseMetaTags, page.data.pageMetaTags));
</script>
<svelte:head>
<title>{site.name}</title>
<link rel="icon" href={favicon} />
</svelte:head>
<MetaTags {...metaTags} />
<ModeWatcher />
<Toaster />
<AdminBar />
{@render children?.()}
The Vite plugin scans the app at build time so the CMS knows every editable field on every page:
import { cms } from "@velastack/cms/vite";
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
import { sveltekit } from "@sveltejs/kit/vite";
import adapter from "@sveltejs/adapter-node";
export default defineConfig({
plugins: [
tailwindcss(),
cms(),
sveltekit({
compilerOptions: {
runes: ({ filename }) =>
filename.split(/[/\\]/).includes("node_modules") ? undefined : true,
},
adapter: adapter(),
// Prerendering has no request to take an origin from, so without this
// every canonical link and `og:url` on a prerendered page would be built
// from SvelteKit's placeholder host. `vela build` sets it from the domain
// the target is deployed on; unset, SvelteKit's default stands.
...(process.env.VELA_ORIGIN
? { prerender: { origin: process.env.VELA_ORIGIN } }
: {}),
}),
],
});
Mark up what is editable
Nothing is editable until you say so. Wrap text in CmsText and images in CmsImage, each with a name for the field and a fallback for when nothing has been published yet:
<script lang="ts">
import { CmsText, CmsImage } from '@velastack/cms';
</script>
<section data-role="content">
<h1><CmsText name="hero.title" fallback="Fresh bread, every morning" /></h1>
<p><CmsText name="hero.subtitle" fallback="Open 7 to 2, Tuesday to Sunday." /></p>
<CmsImage name="hero.image" alt="The counter on a Saturday" />
</section>Names are yours to choose; a dotted prefix per page keeps them tidy. The fallback is what the page shows before an editor has published anything, and it doubles as the placeholder in the editor.
Add an editor
Editors sign in against the CMS’s own table, which starts empty:
✔ Added maria@example.com as an editor of default. Password: 3kQ9xT1vB7mLp2sR It is shown once; copy it now. ℹ Run vela dev, open any page with ?edit on the URL (or press Ctrl+E), and sign in.
vela cms editor list shows who can sign in and vela cms editor password <email> <password> sets a new password.
Try it
Open localhost:5173/?edit, sign in from the admin bar, and click the headline. Edit it in place, upload a photo into the image field, and publish. Open the page in a private window: the new copy is live. Edits stay drafts until published, so an editor can work on a page without visitors seeing half-finished text.
Static sites and hosted CMS
A static site has no server to host the backend, and neither does a project still on adapter-auto. Point it at a hosted CMS instead, and vela build prerenders the published content and downloads the media into the site. A project linked to velastack.dev has one already, so linking is enough:
To name the endpoint yourself, another project’s or your own host’s, pass it:
Editors of a hosted CMS are managed where it is hosted, not with vela cms editor.
The only difference in the generated code is where src/lib/cms.ts reads from; the admin bar signs in at the same place:
import { apiAdapter, createCms } from "@velastack/cms/server";
/**
* The CMS read path, called from the root `+layout.server.ts`.
*
* The CMS is hosted at this endpoint: the app reads published content from
* it and the admin bar signs in there. The same URL is given to the `cms()`
* plugin in vite.config.ts, which downloads media into the build.
*
* `locales` lists every locale the site supports; the first is the default
* that a missing translation falls back to.
*/
export const { load: loadCms, generateEntries } = createCms({
adapter: apiAdapter({
endpoint: "https://velastack.dev/v1/projects/my-site/cms",
}),
locales: ["en"],
});
Going further
- Posts that developers write belong in the blog as files; copy that clients change belongs in the CMS. The two coexist.
- Running the command again fills in only what is missing, so
src/lib/cms.tsedits survive - Pattern page: CMS · Docs: vela enable cms


