Node Types

Defining custom post types from the admin UI.

Post types, but better

If you're coming from WordPress, a node type is the equivalent of a custom post type. Built-in: Page, Post, Documentation. You can register as many custom types as you want — Recipe, Trip, Product, Job, anything. Each type owns a label, an icon, an optional plural label, a description, URL-prefix mappings per language, attached taxonomies, and a custom-field schema that the admin and the public renderer understand identically.

Why it's cheap

Nothing about a node type maps to a SQL table. Every node, of every type, lives in the single content_nodes table; fields_data is a JSONB column on that table. The kernel ships a GIN index on blocks_data (migration 0001) for fast full-document queries; fields_data is unindexed by default and remains fast enough for typical filter-by-key access patterns. Adding a node type is a metadata-only operation — no migration, no downtime, instantly available to admin, public render, and MCP.

Lifecycle

Registering a node type writes a row into node_types. Updating the schema rewrites that row but leaves existing node rows untouched. Deleting the type does not destroy nodes — it marks them dormant, hidden from listings but preserved on disk. Recreating the same slug resurrects them with their fields_data intact. Use this as a safe undo path, especially during early experimentation.

Manage at /admin/node-types

The list view shows slug, label, icon, count of nodes, attached taxonomies. Click New node type to create one, or any row to edit. The same data is editable through MCP via core.nodetype.create / core.nodetype.update.

The fields you set

  • Slug — lowercase, snake_case, immutable after creation (URL paths and stored references depend on it).
  • Label / Label plural — e.g. Recipe / Recipes; the singular shows in detail screens, the plural in sidebar and list headers. Falls back to label if blank.
  • Icon — a Lucide icon name, shown in the admin sidebar. file-text is the default.
  • Description — hint text for editors and AI agents browsing core.nodetype.list.
  • Supports — toggles for excerpt and featured image. A few node types don't need either; turning the toggle off hides the input from the edit screen but does not erase any existing data.
  • URL prefixes — a map of language → prefix, e.g. {en: "recipes", sk: "recepty"}. Public URL is /<prefix>/<slug>. Per-language prefixes let the same node type appear under localised paths.
  • Attached taxonomies — multi-select of taxonomy slugs. Each attached taxonomy renders a sidebar picker on the edit screen.
  • Field schema — the type-level custom fields editor (see Custom Fields).
MCP call
core.nodetype.create({
  slug:         "recipe",
  label:        "Recipe",
  label_plural: "Recipes",
  icon:         "chef-hat",
  description:  "A cookable recipe with timings, servings, and an ingredient list.",
  url_prefixes: { en: "recipes", sk: "recepty" },
  taxonomies:   ["category", "tag"],
  fields: [
    { name: "hero_image",   title: "Hero image",      type: "image",  width: "full"  },
    { name: "prep_minutes", title: "Prep minutes",    type: "number", width: "third" },
    { name: "cook_minutes", title: "Cook minutes",    type: "number", width: "third" },
    { name: "servings",     title: "Servings",        type: "number", width: "third" },
    { name: "ingredients",  title: "Ingredients",     type: "repeater",
      fields: [
        { name: "qty",  title: "Quantity",  type: "text" },
        { name: "item", title: "Item",      type: "text" }
      ]
    }
  ]
})
fields vs field_schema vs key vs name
Through MCP the argument is fields, not field_schema. Each field is {name, title, type, required?, options?, fields?, initialValue?, description?}. From Tengo the same data goes through core/nodetypes.register({field_schema: [...]}) with entries using name. From block.json field schemas, however, entries use key instead of name. The asymmetry is historical; the loaders refuse to translate one form to the other on your behalf because the silent translation would mask far worse bugs.

Editing an existing type

You can change the label, plural, icon, description, supports flags, URL prefixes, attached taxonomies, and field schema at any time. Existing nodes are preserved verbatim — changes only affect new edits. Renaming a field name in the schema does not rewrite existing fields_data; the admin will surface those values in a legacy group on the edit screen so you can see what's there and decide whether to migrate them with a one-shot script.

Migrating field values

The simplest migration is a Tengo seed script that walks every node of the type and rewrites the field. Idempotent on the destination key (skip nodes that already have the new value):

scripts/migrations/recipe_rename.tengo
nodes := import("core/nodes")
log   := import("core/log")

renamed := 0
res := nodes.query({ node_type: "recipe", limit: 200 })
for n in res.nodes {
  if n.fields_data.cooking_time != undefined && n.fields_data.cook_minutes == undefined {
    n.fields_data.cook_minutes = n.fields_data.cooking_time
    n.fields_data.cooking_time = undefined
    nodes.update(n.id, { fields_data: n.fields_data })
    renamed += 1
  }
}
log.info("renamed cooking_time -> cook_minutes", { count: renamed })

Deleting a type

Deleting a node type doesn't delete its nodes — they go dormant (hidden from admin, preserved in DB). Recreating the same slug resurrects them. Use this as a safe undo path during early experimentation; once a type is settled in production, prefer renaming or schema-evolution migrations over delete/recreate so you don't accidentally orphan dependent menu items, term references, or template assumptions.