För utvecklare

Koppla en Next.js-sajt till sitecms

Deklarera vilka fält sajten har, hämta innehållet med en typad klient utan beroenden och låt kunden redigera själv. Allt går att sätta upp utan att prata med oss.

Innehåll

Snabbstart

Kom igång på 10 minuter

Från noll till en kundsajt som hämtar innehåll från sitecms och uppdateras när kunden sparar.
  1. 1

    Skapa konto

    Registrera dig på /signup och bekräfta e-postadressen. Inget kreditkort, ingen väntan.
  2. 2

    Skapa en sajt

    Välj Ny sajt i panelen och ange namn och domän. Sajten får en slug (t.ex. jjbygg) som ingår i alla API-adresser. Du blir sajtens ägare.
  3. 3

    Hämta nycklar

    Öppna Integration för sajten. Skapa en API-nyckel och generera en revalidate-hemlighet och kopiera env-blocket till .env.local och till Vercel.
  4. 4

    Installera sitecms-next

    npm install sitecms-next i kundsajten. Lägg till revalidate-routen (en rad) och ange https://din-domän.se/api/sitecms/revalidate som revalidate-URL i panelen.
  5. 5

    Deklarera fälten

    Beskriv sidor och fält i sitecms.config.ts och lägg "prebuild": "sitecms-next sync" i package.json. Fälten dyker upp i panelen vid nästa build. Tryck sedan på Testa anslutningen.
  6. 6

    Bjud in kunden

    Under Medlemmar bjuder du in kunden som redaktör. Kunden väljer lösenord via mejlet och kan börja redigera — ändringar syns på sajten inom några sekunder.

Miljövariablerna som kundsajten behöver (värdena finns under Integration i panelen):

.env.localenv
CMS_API_URL=https://sitecms.se
CMS_SITE=din-sajt
CMS_API_KEY=<klistra-in-api-nyckeln>
CMS_REVALIDATE_SECRET=<klistra-in-revalidate-hemligheten>

Kontrollera att nycklarna fungerar innan du skriver någon kod:

bash
npx sitecms-next check

Översikt

Hur det hänger ihop

  1. Kundsajten äger strukturen. sitecms.config.ts beskriver vilka sidor och fält som finns. sitecms-next sync skickar den till sitecms före varje build, och panelen visar fälten med dina etiketter och hjälptexter.
  2. Kunden äger innehållet. En synk skriver aldrig över det kunden har skrivit. Nya fält får sitt default-värde; tomma fält fylls med default.
  3. Sajten hämtar med cache. Klienten hämtar med next: { tags: ["sitecms"] }, så sidorna kan vara statiska.
  4. sitecms säger till när något ändrats. Vid varje sparning POST:ar sitecms { "secret": "…" } till din revalidate-URL, som kör revalidateTag("sitecms"). Ändringen syns inom några sekunder.

Rekommenderat

Koppla sajten med npm

Next.js 14.2+ med App Router. Paketet sitecms-next har inga beroenden och innehåller klienten, revalidate-routen och CLI:t för schema-sync.

1. Installera

bash
npm install sitecms-next

Lägg miljövariablerna från Kom igång i .env.local och i Vercel (Production och Preview). Kör npx sitecms-next check för att se att de stämmer.

2. app/api/sitecms/revalidate/route.ts

En rad. Routen jämför secret med CMS_REVALIDATE_SECRET i konstant tid och tömmer cachetaggen sitecms. Ange https://din-domän.se/api/sitecms/revalidate som revalidate-URL under Integration i panelen.

app/api/sitecms/revalidate/route.tsts
export { POST } from "sitecms-next/revalidate";

3. sitecms.config.ts

Vilka sidor och fält kunden ska kunna redigera, med etiketter och hjälptexter som syns i panelen. type är text om det utelämnas. Formatet beskrivs under Schema-format.

sitecms.config.tsts
import { defineConfig } from "sitecms-next";

// Vilka sidor och fält kunden ska kunna redigera. Synkas till sitecms med
// "sitecms-next sync" (prebuild). Kundens innehåll skrivs aldrig över;
// nya fält får sitt default-värde.
export default defineConfig({
  pages: [
    {
      slug: "home",
      title: "Startsida",
      description: "Det besökaren ser först.",
      blocks: [
        { key: "hero.title", label: "Rubrik", help: "Max 60 tecken", default: "Välkommen" },
        {
          key: "hero.image",
          type: "image",
          label: "Hero-bild",
          default: "https://exempel.se/hero.jpg",
          defaultAlt: "Byggarbetare på ett tak",
        },
        { key: "intro.body", type: "richtext", label: "Ingress" },
      ],
    },
  ],
});

4. Synka i prebuild

sitecms-next sync skickar configen till sitecms före varje build. Saknade sidor och fält skapas, kundens innehåll skrivs aldrig över, och bygget stoppas om configen är ogiltig. Saknas miljövariablerna hoppas steget över (lägg till --strict om bygget ska faila då).

package.jsonjson
{
  "scripts": {
    "prebuild": "sitecms-next sync"
  }
}

Vill du synka direkt utan att bygga:

bash
npx sitecms-next sync

5. Använd innehållet

getContent(), getPosts() och getPost(slug) läser CMS_API_URL, CMS_SITE och CMS_API_KEY från miljön och cachar med taggen sitecms, så sidorna kan vara statiska. block() returnerar alltid strängar och är säker att rendera även om fältet saknas.

app/page.tsxtsx
// app/page.tsx
import Image from "next/image";
import { block, getContent, image, text } from "sitecms-next";

export default async function Home() {
  const content = await getContent();
  const hero = image(content, "home", "hero.image");

  return (
    <main>
      <h1>{text(content, "home", "hero.title", "Välkommen")}</h1>
      {hero ? <Image src={hero.src} alt={hero.alt} width={1200} height={630} /> : null}
      <div dangerouslySetInnerHTML={{ __html: block(content, "home", "intro.body").html }} />
    </main>
  );
}

Inlägg:

app/nyheter/[slug]/page.tsxtsx
// app/nyheter/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getPost } from "sitecms-next";

export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
  const post = await getPost((await params).slug);
  if (!post) notFound();
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.bodyHtml }} />
    </article>
  );
}

Flera sajter i samma projekt, eller egna värden:

lib/cms.tsts
import { createSitecms } from "sitecms-next";

export const cms = createSitecms({
  apiUrl: process.env.CMS_API_URL!,
  site: "annan-sajt",
  apiKey: process.env.CMS_API_KEY_ANNAN!,
});

const content = await cms.getContent();

Verktyg

CLI: sitecms-next

Följer med paketet. Läser CMS_API_URL, CMS_SITE och CMS_API_KEY från miljön eller .env.local / .env.
KommandoGör
sitecms-next syncSkickar sitecms.config.ts (eller .mjs/.js/.json) till sitecms och skriver ut vad som skapades. Exit 1 vid fel.
--pruneTar också bort fält och sidor som inte längre finns i configen (inklusive innehållet).
--strictLåter bygget faila om miljövariablerna saknas (annars hoppas synken över).
--config <fil>Använd en annan config-fil.
sitecms-next checkVerifierar nycklarna och visar sajtens namn och senaste sync.
sitecms-next pullSkriver ut strukturen som finns i sitecms som JSON.

Struktur

Schema-format

Kundsajten deklarerar sina sidor och fält, och sitecms skapar strukturen. Samma JSON skickas som body till POST …/schema.
schema.jsonjson
{
  "version": 1,
  "pages": [
    {
      "slug": "home",
      "title": "Startsida",
      "description": "Det besökaren ser först.",
      "blocks": [
        { "key": "hero.title", "type": "text", "label": "Rubrik", "help": "Max 60 tecken", "default": "Välkommen" },
        { "key": "hero.image", "type": "image", "label": "Hero-bild", "default": "https://…/hero.jpg", "defaultAlt": "Byggarbetare" },
        { "key": "intro.body", "type": "richtext", "label": "Ingress" }
      ]
    }
  ],
  "prune": false
}
FältTypBeskrivning
version1Formatversion. Alltid 1.
pages[]arrayMax 100 sidor. Ordningen blir sidornas ordning i panelen.
pages[].slugstringUnik per sajt. Matchar ^[a-z0-9][a-z0-9._-]*$.
pages[].titlestringSidans namn i panelen.
pages[].descriptionstring?En rad under sidans namn i editorn.
pages[].blocks[]arrayMax 200 block per sida. Ordningen blir fältens ordning.
blocks[].keystringUnik per sida, t.ex. hero.title. Samma mönster som slug.
blocks[].typetext | richtext | imageEnrads/flerradstext, formaterad text (markdown → html) eller bild (URL + alt).
blocks[].labelstring?Etiketten kunden ser.
blocks[].helpstring?Hjälptext under fältet.
blocks[].defaultstring?Startvärde när blocket skapas eller är tomt.
blocks[].defaultAltstring?Startvärde för alt-texten (image).
prunebooleanDefault false. true tar bort block och tomma sidor som inte finns i deklarationen.

Regler vid synk

  • Sidor och block som saknas skapas. Nya block får value = default och alt = defaultAlt.
  • Befintliga block får ny type, label, help och ordning — men value/alt skrivs aldrig över. Kundens innehåll vinner. Ett tomt value fylls dock med default.
  • Sidors title, description och ordning uppdateras från deklarationen.
  • Med "prune": false (default) tas inget bort; block som bara finns i CMS:et rapporteras som orphans.
  • Fel i deklarationen ger 400 med en lista över problemen — inget sparas.

Referens

API-referens

Bas-URL: https://sitecms.se/api/v1/sites/{slug}. Alla svar är JSON och cachas inte av sitecms (Cache-Control: private, no-store) — cacha på din sida med next.tags.

Autentisering

Varje anrop kräver headern x-api-key med en nyckel som hör till sajten. Nycklar skapas och tas bort under Integration. Använd dem bara på serversidan — en nyckel ger läsåtkomst till allt publicerat innehåll och kan synka struktur.

bash
curl -H "x-api-key: $CMS_API_KEY" https://sitecms.se/api/v1/sites/din-sajt/content

Hämta allt innehåll

GEThttps://sitecms.se/api/v1/sites/{slug}/content

Sajtens namn, alla sidor med block i ordning, synliga tjänster och SEO per sida. För richtext-block finns färdig html; för bilder alt. label och description kommer från schemat.

200 OKjson
{
  "site": { "name": "JJ Bygg", "domain": "jjbygg.se" },
  "pages": [
    {
      "slug": "home",
      "title": "Startsida",
      "description": "Det besökaren ser först.",
      "blocks": [
        { "key": "hero.title", "type": "text", "label": "Rubrik", "value": "Välkommen", "alt": null, "html": null },
        { "key": "hero.image", "type": "image", "label": "Hero-bild", "value": "https://…/hero.jpg", "alt": "Byggarbetare", "html": null },
        { "key": "intro.body", "type": "richtext", "label": "Ingress", "value": "Vi bygger **tryggt**.", "alt": null, "html": "<p>Vi bygger <strong>tryggt</strong>.</p>" }
      ]
    }
  ],
  "services": [{ "title": "Takbyte", "description": "Komplett takbyte.", "price": "Från 1 200 kr/m²" }],
  "seo": [{ "pageSlug": "home", "title": "JJ Bygg — bygg i Mellerud", "description": "…", "ogImage": null }]
}

Lista inlägg

GEThttps://sitecms.se/api/v1/sites/{slug}/posts

Publicerade inlägg, nyast först, utan brödtext — för listsidor.

200 OKjson
{
  "posts": [
    {
      "slug": "nytt-tak-pa-skolan",
      "title": "Nytt tak på skolan",
      "excerpt": "I veckan blev vi klara med…",
      "coverImage": "https://…/tak.jpg",
      "coverImageAlt": "Nylagt plåttak",
      "publishedAt": "2026-10-12T08:30:00.000Z"
    }
  ]
}

Hämta ett inlägg

GEThttps://sitecms.se/api/v1/sites/{slug}/posts/{postSlug}

Ett publicerat inlägg med body (markdown) och bodyHtml. Utkast och okända slugs ger 404.

200 OKjson
{
  "post": {
    "slug": "nytt-tak-pa-skolan",
    "title": "Nytt tak på skolan",
    "excerpt": "I veckan blev vi klara med…",
    "body": "## Bakgrund\nSkolan behövde…",
    "bodyHtml": "<h2>Bakgrund</h2><p>Skolan behövde…</p>",
    "coverImage": "https://…/tak.jpg",
    "coverImageAlt": "Nylagt plåttak",
    "publishedAt": "2026-10-12T08:30:00.000Z"
  }
}

Läs nuvarande struktur

GEThttps://sitecms.se/api/v1/sites/{slug}/schema

Strukturen i CMS:et i samma format som du skickar, men utan default och med hasValue per block — så att du kan diffa mot sitecms.config.ts.

200 OKjson
{
  "version": 1,
  "pages": [
    {
      "slug": "home",
      "title": "Startsida",
      "description": "Det besökaren ser först.",
      "blocks": [
        { "key": "hero.title", "type": "text", "label": "Rubrik", "help": "Max 60 tecken", "hasValue": true },
        { "key": "hero.image", "type": "image", "label": "Hero-bild", "hasValue": true },
        { "key": "intro.body", "type": "richtext", "label": "Ingress", "hasValue": false }
      ]
    }
  ]
}

Synka struktur

POSThttps://sitecms.se/api/v1/sites/{slug}/schema

Body: ett schema enligt Schema-format med Content-Type: application/json. Svaret berättar vad som skapades, uppdaterades och (med prune) togs bort. Uppdaterar sajtens "Senaste schema-sync" i panelen.

bash
curl -X POST https://sitecms.se/api/v1/sites/din-sajt/schema \
  -H "x-api-key: $CMS_API_KEY" \
  -H "Content-Type: application/json" \
  --data @schema.json
200 OKjson
{
  "ok": true,
  "created": { "pages": 1, "blocks": 3 },
  "updated": { "pages": 0, "blocks": 0 },
  "orphans": [{ "pageSlug": "home", "key": "gammal.rubrik" }],
  "pruned": { "pages": 0, "blocks": 0 }
}
400 Bad Requestjson
{
  "error": "Ogiltigt schema.",
  "issues": [
    "pages[0].blocks[1].type: måste vara text, richtext eller image",
    "pages[0].blocks[2].key: \"Hero Title\" matchar inte ^[a-z0-9][a-z0-9._-]*$"
  ]
}

Kontrollera nyckeln

GEThttps://sitecms.se/api/v1/sites/{slug}/health

Snabb kontroll av att slug och nyckel stämmer, utan att hämta innehåll. Bra i CI och för övervakning.

bash
curl -s -H "x-api-key: $CMS_API_KEY" \
  https://sitecms.se/api/v1/sites/din-sajt/health
200 OKjson
{
  "ok": true,
  "site": { "name": "JJ Bygg", "slug": "jjbygg" },
  "pages": 4,
  "lastSchemaSyncAt": "2026-10-18T09:14:03.000Z"
}

Revalidate-webhook (sitecms → din sajt)

POSThttps://din-domän.se/api/sitecms/revalidate

Varje gång innehåll sparas i panelen skickar sitecms en POST med Content-Type: application/json och body { "secret": "<CMS_REVALIDATE_SECRET>" } till sajtens revalidate-URL (timeout 5 s, inga omförsök). Svara 2xx när hemligheten stämmer och 401 annars.

Testa anslutningen på integrationssidan skickar exakt samma anrop (timeout 8 s) och visar statuskoden. Följer inte omdirigeringar — ange den slutliga adressen.

bash
curl -s -X POST https://exempel.se/api/sitecms/revalidate \
  -H "Content-Type: application/json" \
  -d '{"secret":"'"$CMS_REVALIDATE_SECRET"'"}'
200 OKjson
{ "revalidated": true }

Felkoder

Fel returneras som { "error": "…" } med en svensk beskrivning (schema-fel har även issues).

StatusBetyderVanlig orsak
400Ogiltig förfråganSchemat följer inte formatet (fel slug/key, okänd typ, fler än 100 sidor eller 200 block per sida), eller body är inte giltig JSON. Se issues.
401Nyckel saknas eller är felHeadern x-api-key saknas, nyckeln är borttagen eller hör till en annan sajt.
404Hittades inteOkänd sajt-slug, eller inlägget finns inte / är inte publicerat.
413För stortRequest-bodyn är för stor för att tas emot. Kontrollera att schemat inte innehåller stora default-värden (t.ex. base64-bilder i stället för URL:er).
401 Unauthorizedjson
{ "error": "Ogiltig API-nyckel för denna sajt." }

Alternativ

Utan npm (copy-paste)

Samma funktionalitet som sitecms-next, som filer i ditt eget repo. Inga beroenden utöver tsx för synkskriptet. På integrationssidan i panelen är sajtens slug och CMS-adress redan ifyllda.

lib/sitecms.ts

Typad klient: getContent(), getPosts(), getPost(slug) och hjälparna block(content, sida, nyckel) → { value, html, alt }, text(), image() och seo(). block() returnerar alltid strängar, så den är säker att rendera även om fältet saknas.

lib/sitecms.tsts
// lib/sitecms.ts — sitecms-klient för Next.js App Router. Inga beroenden.
// Allt hämtas med cache-taggen "sitecms" och töms av
// app/api/sitecms/revalidate/route.ts när innehåll sparas i panelen.

const API_URL = (process.env.CMS_API_URL ?? "https://sitecms.se").replace(/\/+$/, "");
const SITE = process.env.CMS_SITE ?? "din-sajt";

export type BlockType = "text" | "richtext" | "image";

export type Block = {
  key: string;
  type: BlockType;
  /** Text, markdown (richtext) eller bild-URL (image). */
  value: string | null;
  /** Bildbeskrivning för image-block, annars null. */
  alt: string | null;
  /** Färdig HTML för richtext-block, annars null. */
  html: string | null;
  label?: string | null;
};

export type Page = {
  slug: string;
  title: string;
  description?: string | null;
  blocks: Block[];
};

export type Service = {
  title: string;
  description: string | null;
  price: string | null;
};

export type Seo = {
  pageSlug: string;
  title: string | null;
  description: string | null;
  ogImage: string | null;
};

export type Content = {
  site: { name: string; domain: string | null };
  pages: Page[];
  services: Service[];
  seo: Seo[];
};

export type PostSummary = {
  slug: string;
  title: string;
  excerpt: string | null;
  coverImage: string | null;
  coverImageAlt: string | null;
  publishedAt: string | null;
};

export type Post = PostSummary & {
  /** Markdown. */
  body: string | null;
  /** body renderad till HTML. */
  bodyHtml: string;
};

export class SitecmsError extends Error {
  constructor(
    message: string,
    readonly status: number,
  ) {
    super(message);
    this.name = "SitecmsError";
  }
}

async function request<T>(path: string): Promise<T | null> {
  const res = await fetch(`${API_URL}/api/v1/sites/${encodeURIComponent(SITE)}${path}`, {
    headers: { "x-api-key": process.env.CMS_API_KEY! },
    next: { tags: ["sitecms"] },
  });

  if (res.status === 404 && path.startsWith("/posts/")) return null;

  if (!res.ok) {
    const body = (await res.json().catch(() => null)) as { error?: string } | null;
    throw new SitecmsError(`sitecms: ${body?.error ?? res.statusText} (HTTP ${res.status}, ${path})`, res.status);
  }

  return (await res.json()) as T;
}

/** Hela sajtens innehåll: sidor med block, tjänster och SEO. */
export async function getContent(): Promise<Content> {
  return (await request<Content>("/content"))!;
}

/** Publicerade inlägg, nyast först (utan brödtext). */
export async function getPosts(): Promise<PostSummary[]> {
  const data = await request<{ posts: PostSummary[] }>("/posts");
  return data?.posts ?? [];
}

/** Ett publicerat inlägg, eller null om det inte finns. */
export async function getPost(slug: string): Promise<Post | null> {
  const data = await request<{ post: Post }>(`/posts/${encodeURIComponent(slug)}`);
  return data?.post ?? null;
}

function escapeHtml(value: string): string {
  return value
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

/**
 * Ett block som alltid går att rendera: tomma strängar i stället för null.
 * `html` är färdig HTML för richtext och HTML-escapad text för övriga typer.
 */
export function block(content: Content, pageSlug: string, key: string): { value: string; html: string; alt: string } {
  const found = content.pages.find((p) => p.slug === pageSlug)?.blocks.find((b) => b.key === key);
  const value = found?.value ?? "";
  return {
    value,
    html: found?.html ?? escapeHtml(value),
    alt: found?.alt ?? "",
  };
}

/** Textvärdet för ett block, eller `fallback` om det är tomt. */
export function text(content: Content, pageSlug: string, key: string, fallback = ""): string {
  return block(content, pageSlug, key).value || fallback;
}

/** Bild-URL + alt-text för ett image-block, eller null om ingen bild är vald. */
export function image(content: Content, pageSlug: string, key: string): { src: string; alt: string } | null {
  const { value, alt } = block(content, pageSlug, key);
  return value ? { src: value, alt } : null;
}

/** SEO-uppgifter för en sida (passar i generateMetadata). */
export function seo(content: Content, pageSlug: string): Seo | null {
  return content.seo.find((s) => s.pageSlug === pageSlug) ?? null;
}

Användning i en sida:

app/page.tsxtsx
// app/page.tsx — exempel
import Image from "next/image";
import { block, getContent, image, text } from "@/lib/sitecms";

export default async function Home() {
  const content = await getContent();
  const hero = image(content, "home", "hero.image");

  return (
    <main>
      <h1>{text(content, "home", "hero.title", "Välkommen")}</h1>
      {hero ? <Image src={hero.src} alt={hero.alt} width={1200} height={630} /> : null}
      <div dangerouslySetInnerHTML={{ __html: block(content, "home", "intro.body").html }} />
    </main>
  );
}

app/api/sitecms/revalidate/route.ts

Tar emot webhooken från sitecms, jämför hemligheten i konstant tid och tömmer cachetaggen. Ange https://din-domän.se/api/sitecms/revalidate som revalidate-URL i panelen.

app/api/sitecms/revalidate/route.tsts
// app/api/sitecms/revalidate/route.ts — sitecms anropar den här när innehåll sparas.
import { createHash, timingSafeEqual } from "node:crypto";
import { revalidateTag } from "next/cache";

export const dynamic = "force-dynamic";

/** Jämför i konstant tid (hashar först så längden inte läcker). */
function safeEqual(a: string, b: string): boolean {
  const ha = createHash("sha256").update(a).digest();
  const hb = createHash("sha256").update(b).digest();
  return timingSafeEqual(ha, hb);
}

export async function POST(request: Request) {
  const expected = process.env.CMS_REVALIDATE_SECRET;
  if (!expected) {
    return Response.json({ revalidated: false, error: "CMS_REVALIDATE_SECRET saknas" }, { status: 500 });
  }

  const body = (await request.json().catch(() => null)) as { secret?: unknown } | null;
  const secret = typeof body?.secret === "string" ? body.secret : "";

  if (!secret || !safeEqual(secret, expected)) {
    return Response.json({ revalidated: false, error: "Fel hemlighet" }, { status: 401 });
  }

  // Next.js 16: { expire: 0 } ger färskt innehåll direkt (formen med bara taggen är utfasad).
  // Next.js 14–15 tar bara taggen och ignorerar det andra argumentet.
  (revalidateTag as (tag: string, profile?: { expire?: number }) => void)("sitecms", { expire: 0 });
  return Response.json({ revalidated: true });
}

sitecms.config.ts

Typad deklaration av sidor och fält. Formatet beskrivs under Schema-format.

sitecms.config.tsts
// sitecms.config.ts — vilka sidor och fält sajten har.
// Synkas till sitecms med scripts/sitecms-sync.ts (körs före varje build).
// Befintligt innehåll skrivs aldrig över; nya fält får sitt default-värde.

export type SitecmsBlock = {
  /** Unik per sida. a–z, 0–9, punkt, bindestreck, understreck. T.ex. "hero.title". */
  key: string;
  type: "text" | "richtext" | "image";
  /** Etiketten kunden ser i panelen. */
  label?: string;
  /** Hjälptext under fältet. */
  help?: string;
  /** Startvärde (text, markdown eller bild-URL). */
  default?: string;
  /** Startvärde för bildens alt-text (bara image). */
  defaultAlt?: string;
};

export type SitecmsPage = {
  slug: string;
  title: string;
  description?: string;
  blocks: SitecmsBlock[];
};

export type SitecmsConfig = {
  version: 1;
  pages: SitecmsPage[];
  /** true tar bort block och tomma sidor som inte längre finns här. */
  prune?: boolean;
};

const config: SitecmsConfig = {
  version: 1,
  pages: [
    {
      slug: "home",
      title: "Startsida",
      description: "Det besökaren ser först.",
      blocks: [
        { key: "hero.title", type: "text", label: "Rubrik", help: "Max 60 tecken", default: "Välkommen" },
        {
          key: "hero.image",
          type: "image",
          label: "Hero-bild",
          default: "https://exempel.se/hero.jpg",
          defaultAlt: "Byggarbetare",
        },
        { key: "intro.body", type: "richtext", label: "Ingress" },
      ],
    },
  ],
  prune: false,
};

export default config;

scripts/sitecms-sync.ts

Läser sitecms.config.ts, POST:ar den till …/schema och skriver ut vad som skapades, uppdaterades och vilka fält som bara finns i CMS:et. Avslutar med felkod 1 vid fel, så att bygget stoppas innan en trasig struktur går ut. Läser env från miljön eller .env.local/.env.

scripts/sitecms-sync.tsts
// scripts/sitecms-sync.ts — skickar sitecms.config.ts till sitecms.
// Kör: npx tsx scripts/sitecms-sync.ts
//  (eller node --experimental-strip-types scripts/sitecms-sync.ts)
// Läser CMS_API_URL, CMS_SITE och CMS_API_KEY från miljön eller .env.local / .env.
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import type { SitecmsConfig } from "../sitecms.config";

type SyncReport = {
  ok: true;
  created: { pages: number; blocks: number };
  updated: { pages: number; blocks: number };
  orphans: { pageSlug: string; key: string }[];
  pruned?: { pages?: number; blocks?: number };
};

type SyncError = { error?: string; issues?: unknown[] };

function loadEnv(): void {
  for (const file of [".env.local", ".env"]) {
    if (existsSync(file)) {
      try {
        process.loadEnvFile(file);
      } catch {
        // Ogiltig rad i filen — variablerna kan fortfarande komma från miljön.
      }
    }
  }
}

async function loadConfig(): Promise<SitecmsConfig> {
  const file = resolve(process.cwd(), "sitecms.config.ts");
  if (!existsSync(file)) throw new Error(`hittar inte ${file}`);
  let mod: unknown = await import(pathToFileURL(file).href);
  // tsx (CommonJS) kan slå in default-exporten en extra gång.
  while (mod && typeof mod === "object" && "default" in mod && !("pages" in mod)) {
    mod = (mod as { default: unknown }).default;
  }
  return mod as SitecmsConfig;
}

function fail(message: string): never {
  console.error(`✖ sitecms-sync: ${message}`);
  process.exit(1);
}

async function main(): Promise<void> {
  loadEnv();
  const apiUrl = process.env.CMS_API_URL?.replace(/\/+$/, "");
  const site = process.env.CMS_SITE;
  const apiKey = process.env.CMS_API_KEY;
  if (!apiUrl || !site || !apiKey) {
    fail("CMS_API_URL, CMS_SITE och CMS_API_KEY måste vara satta (t.ex. i .env.local).");
  }

  const config = await loadConfig();
  const url = `${apiUrl}/api/v1/sites/${encodeURIComponent(site)}/schema`;

  const res = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json", "x-api-key": apiKey },
    body: JSON.stringify(config),
    signal: AbortSignal.timeout(30_000),
  });
  const data = (await res.json().catch(() => null)) as (SyncReport & SyncError) | null;

  if (!res.ok || !data?.ok) {
    console.error(`✖ sitecms-sync: HTTP ${res.status} från ${url}`);
    if (data?.error) console.error(`  ${data.error}`);
    for (const issue of data?.issues ?? []) {
      const i = issue as { path?: unknown; message?: unknown };
      const line =
        typeof issue === "string"
          ? issue
          : typeof i?.message === "string"
            ? `${typeof i.path === "string" ? `${i.path}: ` : ""}${i.message}`
            : JSON.stringify(issue);
      console.error(`  - ${line}`);
    }
    process.exit(1);
  }

  console.log(`✔ sitecms-sync: ${config.pages.length} sidor synkade till ${site}`);
  console.log(`  skapade: ${data.created.pages} sidor, ${data.created.blocks} block`);
  console.log(`  uppdaterade: ${data.updated.pages} sidor, ${data.updated.blocks} block`);
  if (data.pruned && (data.pruned.pages || data.pruned.blocks)) {
    console.log(`  borttagna: ${data.pruned.pages ?? 0} sidor, ${data.pruned.blocks ?? 0} block`);
  }
  if (data.orphans.length > 0) {
    console.log(`  finns i CMS:et men inte i configen (${data.orphans.length}):`);
    for (const o of data.orphans) console.log(`    - ${o.pageSlug} / ${o.key}`);
    console.log("  Sätt prune: true i sitecms.config.ts för att ta bort dem.");
  }
}

main().catch((err: unknown) => fail(err instanceof Error ? err.message : String(err)));

Installera tsx och kör synken före varje build. Utan tsx (Node 22.6 eller senare) fungerar node --experimental-strip-types scripts/sitecms-sync.ts.

bash
npm install --save-dev tsx
package.jsonjson
{
  "scripts": {
    "sitecms:sync": "tsx scripts/sitecms-sync.ts",
    "prebuild": "tsx scripts/sitecms-sync.ts"
  }
}