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-textis 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).
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, 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):
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.