CLI

npm install -D @blokcms/cli
npx blokcms login --token blok_pat_…        # Account → Access tokens

Or set BLOKCMS_TOKEN (and BLOKCMS_API_URL) in CI.

Command
blokcms spacesList your organization's spaces.
blokcms schema pull --space spc_… [--out blokcms.schema.ts]Write the content model (types, option lists, locales and forms) to blokcms.schema.json, or a .ts file. Form secrets come out as environment variables, never as values.
blokcms schema push --space spc_… blokcms.schema.ts [--prune] [--dry-run] [--allow-destructive]Create and update types from code, after printing what each change does to content. --prune deletes types missing from the file, and the forms and lists missing from those sections (not forms with submissions); changes that delete or lossily convert values, or make published entries invalid, need --allow-destructive.
blokcms types generate --space spc_… --out src/blokcms.d.tsTypeScript types for your content.
blokcms migrate --space spc_… migrations/001.ts [--dry-run]Run a content migration.
blokcms export --space spc_… --out space.ndjsonExport everything (runs as a background job).
blokcms import --space spc_… space.ndjsonImport an export (upserts by id). Files come along: from the same organization directly, from anywhere else (another environment) through the download URL each export carries.
blokcms assets import --space spc_… <https URL or file>… [--folder afd_…]Add files to the media library. URLs are downloaded by BlokCms (https, public websites, up to 100 MB); files it already has (same content) aren't added twice.

Schema as code

The helpers type-check every field and setting. Field types and settings are described in Modeling content.

import { defineSchema, defineType, field } from "@blokcms/cli";

export default defineSchema({
  types: [
    defineType({
      key: "page", kind: "content", name: "Page",
      fields: [
        field.text("title", "Title", { required: true, translatable: true }),
        field.blocks("body", "Body", { types: ["hero"], initial: [{ type: "hero" }] }),
        field.seo("seo", "SEO", { tab: "SEO" }),
      ],
    }),
    defineType({
      key: "hero", kind: "block", name: "Hero", titleField: "headline",
      fields: [
        field.text("headline", "Headline", { required: true }),
        field.asset("images", "Images", { multiple: true, accept: ["image/"] }),
      ],
    }),
  ],
});

Besides types, a schema file can hold what a site's code depends on: lists (option lists, with all their options), locales (added or updated, never removed; the default stays as created) and forms. Each section is only touched when the file has it. Secrets don't belong in the file: read them from the environment.

export default defineSchema({
  types: [/* … */],
  locales: [{ code: "fr-BE", name: "Français", fallback: "nl-BE" }],
  lists: [{ key: "regions", name: "Regions", items: [{ value: "north", label: "North" }] }],
  forms: [{
    key: "contact", name: "Contact",
    fields: [{ key: "email", label: "Email", type: "email", required: true }],
    settings: { successMessage: "Thanks!", turnstileSiteKey: "0x4AAA…", turnstileSecret: process.env.TURNSTILE_SECRET },
  }],
});

Form settings are merged: one left out keeps its value, so a secret whose variable isn't set stays as it is. In JSON files, write { "$env": "TURNSTILE_SECRET" } instead; schema pull does that for every secret that is set (BLOK_FORM_<KEY>_TURNSTILE_SECRET). Menus aren't part of the schema: they're content, and editors own them.

schema pull writes every field's id. Keep it when you rename a field: the field's content moves to the new key. In a file written by hand, add renamedFrom: "oldKey" to the renamed field instead. A field you leave out is deleted with its values, so schema push asks for --allow-destructive.

Content migrations

import { defineMigration } from "@blokcms/cli";

export default defineMigration(async ({ entries, getEntry, saveDraft, log }) => {
  for await (const summary of entries("article")) {
    const entry = await getEntry(summary.id);
    const fields = { ...entry.draft.fields, subtitle: entry.draft.fields.teaser };
    await saveDraft(entry.id, entry.version, { ...entry.draft, fields }, "Copy teaser to subtitle");
    log(`updated ${summary.path}`);
  }
});

Seeds and migrations from another CMS have two more helpers: uploadAsset(urlOrPath, { alt, folderId }) adds a file (by URL or from disk) and returns { asset, duplicate } without storing a file twice, and blockId(seed) gives a block the same id on every run, so an unchanged entry isn't saved again. The Management API finds entries by path too: GET /v1/spaces/spc_…/entries/by-path/blog/hello (drafts included).

export default defineMigration(async ({ uploadAsset, blockId, api, spaceId }) => {
  const { asset } = await uploadAsset("https://old-site.example.com/images/team.jpg", { alt: "Our team" });
  const page = await api.get(`/spaces/${spaceId}/entries/by-path/about`);
  // … a block { id: blockId("about/team"), type: "image", fields: { image: { asset: asset.id } } }
});