# How to let clients edit a SvelteKit site themselves

An admin bar on your own site where editors change copy in place, upload images and publish, with no redeploy. vela enable cms hosts the CMS inside your app, or points a static site at a hosted one, and you mark up which text is editable.

Pattern: [Enable CMS](https://velastack.dev/patterns/enable-cms) · Docs: https://docs.velastack.dev/enable/cms

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](https://docs.velastack.dev/bless)). 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

```sh
$ vela enable cms
✔ 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:

**src/lib/server/cms.ts**

```ts
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"),
});
```

**src/routes/api/cms/[...path]/+server.ts**

```ts
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>`:

**src/routes/uploads/[filename]/+server.ts**

```ts
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:

**src/lib/cms.ts**

```ts
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"],
});
```

**src/routes/+layout.server.ts**

```ts
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,
        },
      ],
    },
  });

  // [!code highlight:6]
  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:

**src/routes/+layout.svelte**

```svelte
<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";
  // [!code highlight:1]
  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 />
<!-- [!code highlight:1] -->
<AdminBar />

{@render children?.()}
```

The Vite plugin scans the app at build time so the CMS knows every editable field on every page:

**vite.config.ts**

```ts
// [!code highlight:1]
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(),
    // [!code highlight:1]
    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:

**src/routes/(public)/+page.svelte**

```svelte
<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:

```sh
$ vela cms editor add maria@example.com
✔ 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

```sh
$ vela dev
```

Open [localhost:5173/?edit](http://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:

```sh
$ vela link && vela enable cms
```

To name the endpoint yourself, another project's or your own host's, pass it:

```sh
$ vela enable cms --endpoint https://velastack.dev/v1/projects/my-site/cms
```

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:

**src/lib/cms.ts**

```ts
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"],
});
```

> **Note:**
> With [i18n](https://velastack.dev/tutorials/sveltekit-translate-your-site) enabled first, the pattern reads the site's locales from Wuchale instead of a single default, and editors get one value per language for every field.

## Going further

- Posts that developers write belong in [the blog](https://velastack.dev/tutorials/sveltekit-markdown-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.ts` edits survive
- [Pattern page: CMS](https://velastack.dev/patterns/enable-cms) · [Docs: vela enable cms](https://docs.velastack.dev/enable/cms)
