Add an inline-editing CMS to your SvelteKit site. An app with a backend hosts the CMS itself: the pattern mounts the @velastack/cms backend at /api/cms with its SQLite database and uploads in the data directory. A static site, or any app, can instead read from a hosted CMS with --endpoint, and vela build prerenders the published content with its media. Either way it calls loadCms from the root +layout.server.ts so every page receives its content and deleted pages answer 404, 410 or a redirect, and renders an <AdminBar /> where editors sign in to edit copy in place, upload media, and publish. Wrap text in <CmsText> and images in <CmsImage> to make them editable; the Vite plugin finds every field at build time. With i18n enabled, the CMS speaks the site's locales.
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 { 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;
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;
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,
};
});
<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?.()}
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 } }
: {}),
}),
],
});