Skip to content

Svelte binding

better-content/svelte binds the engine to Svelte with the platform's own primitives: the store contract and element actions. It has no dependency on the svelte package and works with Svelte 4 and 5 (stores and actions are supported in both).

Setup

Create one engine for the page and share it however you prefer (module scope, context, a prop):

ts
// lib/cms.ts
import { createCmsEngine, restTransport } from "better-content/core";

export const engine = createCmsEngine({
  transport: restTransport(),
  initialItems,   // from your load function
});

Stores

svelte
<script lang="ts">
  import { engineStore, itemStore } from "better-content/svelte";
  import { engine } from "$lib/cms";

  const snapshot = engineStore(engine);        // whole-engine store
  const hero = itemStore(engine, "sections", "hero");  // one item, fine-grained
</script>

<button
  on:click={() => engine.saveAll()}
  disabled={!$snapshot.hasUnsavedChanges || $snapshot.saving}
>
  {$snapshot.saving ? "Saving" : "Save all"}
</button>

<p>{$hero?.tagline}</p>

Both implement Svelte's readable-store contract, so $ auto-subscription just works. itemStore only emits when its item's reference changes, the same identity-based granularity as React's useCmsItem: editing one item does not wake subscribers of another.

Inline editing with the action

svelte
<script lang="ts">
  import { contentEdit } from "better-content/svelte";
  import { engine } from "$lib/cms";

  let editing = false;
</script>

<button on:click={() => (editing = !editing)}>
  {editing ? "Done" : "Edit"}
</button>

<h1 use:contentEdit={{ engine, collection: "sections", itemId: "hero", fieldKey: "heading", editing }} />
<p use:contentEdit={{ engine, collection: "sections", itemId: "hero", fieldKey: "tagline", editing }} />

The action renders the field's current value into the element, keeps it in sync with engine changes, and when editing is true makes it contenteditable. Drafts stay DOM-owned while focused; blur commits through editField (deferred, so the save button decides when it persists). Multi-line input is preserved as \n and rendered with white-space: pre-wrap.

Styling hooks are the same attributes the React primitive uses:

css
[data-cms-editing] { outline: 1px dashed #999; }
[data-cms-focused] { outline: 2px solid #4a90d9; }

Because the params object includes editing, Svelte re-runs the action's update whenever your edit-mode state changes; no wrapper components needed.

SvelteKit

Load content on the server with the same server module React apps use:

ts
// +page.server.ts
import { loadItemMap } from "better-content/server";

export async function load() {
  return { initialItems: await loadItemMap(data, { sections: {} }) };
}

Mount the API with createCmsHandlers in a src/routes/api/admin/[collection]/[id]/+server.ts endpoint; the handlers take web-standard Request/Response, which is exactly what SvelteKit endpoints speak.

Image editing

imageEdit returns a readable store plus methods, with the same semantics as React's EditableImage: picking a file previews it immediately via an object URL and queues a pending upload that the engine flushes on save; external URLs are validated as http(s) and queued without a file.

svelte
<script lang="ts">
  import { imageEdit } from "better-content/svelte";
  import { engine } from "$lib/cms";

  export let editing: boolean;

  const cover = imageEdit(engine, {
    collection: "sections",
    itemId: "hero",
    fieldKey: "cover",
  });
</script>

<figure on:click={() => editing && cover.openFilePicker()}>
  {#if $cover.src}
    <img src={$cover.src} alt="Cover" on:error={cover.handleError} />
  {:else}
    <span>no cover yet</span>
  {/if}
</figure>

Give the engine a storage adapter (see Image storage), or file uploads have nowhere to go when the save flushes.

Markdown editing

markdownEdit is the same headless primitive React ships, with no toolbar, no preview, and no parser. It holds a draft string, wraps the textarea's current selection on insert, and hands the draft to your onSave callback.

svelte
<script lang="ts">
  import { markdownEdit } from "better-content/svelte";
  import { engine } from "$lib/cms";

  export let id: string;
  export let body: string;

  const md = markdownEdit({
    initialValue: body,
    onSave: (content) => engine.editField("posts", id, "body", content),
  });
  const { textarea } = md;
</script>

<div class="toolbar">
  <button type="button" on:click={() => md.insert("**", "**", "bold")}>bold</button>
  <button type="button" on:click={() => md.insert("[", "](url)", "link")}>link</button>
</div>
<textarea
  use:textarea
  value={$md.value}
  on:input={(e) => md.setValue(e.currentTarget.value)}
></textarea>
<p>{$md.charCount} characters</p>
<button type="button" on:click={() => md.reset()}>Reset</button>
<button type="button" on:click={() => md.save()}>Save</button>

Destructure textarea first so use:textarea is a plain identifier. Without the action attached, insert has no selection to read and appends at the end of the value instead. With a selection it wraps the selected text; with an empty one it inserts the placeholder, and either way the caret lands inside the wrap so you can keep typing.

Unlike the stores above it takes no CmsEngine. The draft is local until save runs, which is what makes committing it through editField, posting it somewhere else, or discarding it your decision.

MIT. Independent project, not affiliated with the better-* family.