Skip to main content

Post Types

An app can register its own post types in app.json: a named list of records the merchant manages in the SitePack admin, with the fields your app defines. Think of the cinemas of a cinema advertising network, the branches of a franchise, the members of a club or the brands of a distributor. You describe the post type once; SitePack stores the records, gives the merchant a screen to manage them and hands them to themes and app templates.

A post type is either:

  • private: only readable from Twig with sitepack_posts() and sitepack_post(), in themes and in your app's own templates. Good for data a theme lists somewhere (logos, partners, team members) that needs no page of its own.
  • public: readable the same way, and the storefront serves it under its own address: /<slug> for the overview and /<slug>/<entry-slug> for one entry, for example /cinemas and /cinemas/grand-central.

Declaring a post type (post_types)​

{
"uuid": "01a0f000-0000-7000-8000-000000000001",
"name": "Cinema Network",
"version": 1,
"post_types": [
{
"key": "cinemas",
"name": "post_types.cinemas.name",
"singular_name": "post_types.cinemas.singular_name",
"public": true,
"slug": "cinemas",
"icon": "fas fa-film",
"sort": "title",
"templates": {
"archive": "cinemas.twig",
"entry": "cinema.twig"
},
"fields": [
{"key": "image", "type": "image", "label": "Photo"},
{"key": "city", "type": "text", "label": "City", "required": true},
{"key": "country", "type": "options", "label": "Country", "options": [
{"value": "nl", "label": "The Netherlands"},
{"value": "be", "label": "Belgium"}
]},
{"key": "screens", "type": "number", "label": "Screens"},
{"key": "website", "type": "url", "label": "Website"},
{"key": "description", "type": "textarea", "label": "Description", "help": "A few sentences about the cinema."}
]
}
]
}
KeyRequiredDescription
keyyesHow themes and the storefront address the post type: lowercase letters, digits and dashes, at most 50 characters. It is stable: changing it in a new version detaches the entries merchants already made.
nameyesThe plural label, shown under Content in the admin sidebar and as the page title of the overview.
singular_namenoThe label of one entry ("Add Cinema"). Defaults to name.
publicnotrue gives the post type storefront routes. Default false (private).
slugnoThe first path segment of a public post type. Defaults to key. Same format as key.
iconnoA Font Awesome class for the admin menu, such as "fas fa-film". Defaults to a document icon.
archivenoPublic post types only: false gives the post type entry pages but no overview at /<slug>, so it can share its slug with another post type. Default true.
templatesnoPublic post types only: your own Twig templates for the storefront pages, {"archive": "cinemas.twig", "entry": "cinema.twig"}, files under your app's templates/. See Shipping your own page templates.
sortnoThe order entries are listed in, in the admin and in Twig: title (default, A to Z), newest (most recently created first) or position (a number the merchant sets per entry, then title).
fieldsnoThe fields of an entry, in the order the admin form shows them. See below.

name, singular_name, every field label and help and every option label can be a translation key: the admin shows them in the merchant's language, and the storefront gets them in the site's language.

Every entry already has a title (required) and a slug (made from the title unless the merchant sets one, unique within the post type), plus a visible switch. You do not declare those.

Field types​

typeAdmin inputValue in Twig
textOne-line textstring
textareaMulti-line textstring (print with nl2br)
numberNumberinteger or float
imageMedia library pickerabsolute CDN URL of the image
urlTexta https:// address or a path on the site (/contact)
emailEmailstring
dateDate pickerYYYY-MM-DD
booleanSwitchtrue / false
optionsSelectthe chosen option's value

Each field takes key (lowercase letters, digits, - and _), type, label, and optionally required: true, help (a hint under the input), column: true (show it as a column in the admin table, see below) and, for options, an options list of strings or {"value": "...", "label": "..."} objects.

A field that is empty is null in Twig (false for a boolean). A field key cannot be one of uuid, title, slug, url, fields, position, visible, created or modified.

SitePack checks every value when the merchant saves: numbers must be numbers, an image must come from the merchant's media library, a URL cannot be a javascript: or data: address, an option must be one of yours. So what reaches your template is always the declared type.

Reserved slugs​

A public post type cannot take a first path segment the storefront uses itself, such as account, blog, cart, categories, checkout, legal, products, search or sitemap. A post type with such a slug keeps its entries and Twig access but gets no routes, and the review checklist flags it.

What the merchant sees​

Once your app is installed, each post type appears under Content in the admin sidebar, right below the agenda. It opens a paged, searchable table of the entries (25 per page, searched on title) with add, edit and delete, a visibility switch per row and a form built from your fields. Entries of a public post type show their address and an open button to their storefront page.

The merchant can also import and export the entries as CSV. The export has one column per built-in property (title, slug, visible) and one per field, headed by the field key, so it imports back without any mapping. On import the merchant maps each CSV column to a property or field (SitePack suggests a mapping from the column names, matching your field keys and labels) and chooses whether rows update the entry with the same slug. Every value goes through the same checks as the form, so give your fields clear labels: they are what the merchant maps to.

Columns in the admin table​

Mark the fields the merchant should see in the table with "column": true. They appear in the order of fields, after the title; the first image column becomes the thumbnail in front of the title, and long text is shortened.

"fields": [
{"key": "image", "type": "image", "label": "Photo", "column": true},
{"key": "city", "type": "text", "label": "City", "column": true},
{"key": "screens", "type": "number", "label": "Screens", "column": true},
{"key": "description", "type": "textarea", "label": "Description"}
]

Without any column field the table picks for you: the first image field as thumbnail and the first two short fields (text, number, options, date, email, boolean) as columns. Keep it to three or four columns so the table stays readable.

When the app is uninstalled, its post types disappear from the admin and the storefront, but the entries are kept. Installing the app again brings them back.

Reading entries in Twig​

Both functions work in any theme template and in your app's block templates.

{# All visible entries, in the post type's sort order #}
{% for cinema in sitepack_posts('cinemas') %}
<a href="{{ sitepack_post_slug(cinema) }}">
{% if cinema.fields.image %}<img src="{{ cinema.fields.image }}" alt="{{ cinema.title }}">{% endif %}
{{ cinema.title }} &middot; {{ cinema.fields.city }}
</a>
{% endfor %}

{# At most six #}
{% set cinemas = sitepack_posts('cinemas', 6) %}

{# One entry by its slug, or null #}
{% set flagship = sitepack_post('cinemas', 'grand-central') %}

An entry is a plain object:

PropertyDescription
uuidStable id of the entry.
typeThe post type key.
title / slugThe entry's title and slug.
urlThe storefront path without a language prefix (/cinemas/grand-central), or null for a private post type. Link with sitepack_post_slug(entry) instead, which adds the language prefix.
positionThe number used by sort: "position".
fieldsYour fields, by key: entry.fields.city. Every declared field is present.
seo{title, description, no_index}: the SEO texts the merchant set for the entry's page (each null when not set) and whether it is excluded from search engines.
created / modifiedISO 8601 timestamps.

Hidden entries are never returned. See the functions reference for the full signatures.

Caching​

The storefront caches entries the same way it caches content pages, so you do not need to cache them yourself:

  • Every list and entry is cached for up to an hour after the first page that asks for it. After that, rendering a post type page or calling sitepack_posts() / sitepack_post() costs no request to SitePack.
  • Each different call is cached on its own: sitepack_posts('cinemas') and sitepack_posts('cinemas', 6) are two entries, and so is each language. Within one page the same call is made once, however often your theme and app blocks repeat it. Prefer a few fixed limits over a limit that changes per page.
  • An entry that does not exist is cached as well, so an unknown slug does not reach SitePack on every visit.
  • Changes show up without any work on your side. When the merchant (or the API, the MCP tools, a CSV import) adds, edits, hides or deletes an entry, saves the overview page's SEO or saves or deletes a translation, the site's whole post cache is cleared: every list, entry and overview, in every language. Installing or updating an app clears it too, as it may change the post types. The XML sitemap is refreshed at the same moment.
  • Clearing runs in the background, like every other cache update on SitePack, so a change shows on the storefront once that background job has run, not necessarily on the very next request.
  • On an overview address (/cinemas) the storefront remembers for a few minutes that the merchant has no page there. A page the merchant then saves at that address, or translates there, clears this right away in every language.

Storefront pages of a public post type​

A public post type answers two addresses (with the language prefix on a translated site, /de/cinemas/...). For each, the storefront picks the first template that exists:

Address1. Theme, for this post type2. Your app's template3. Theme, generic
/<slug> (overview)posts-<key>.twigtemplates.archive, inside the theme's page.twigposts.twig, then page.twig
/<slug>/<entry-slug>post-<key>.twigtemplates.entry, inside the theme's page.twigpost.twig, then page.twig

Theme templates get posts (all visible entries) or post (the entry), and post_type, which carries key, name, singular_name, public, slug, sort, fields (the definitions, with translated labels) and templates. When the theme falls back to page.twig, the entry's title and fields are also on page.

Themes built on SitePack's standard layouts (not a custom theme) show your app's template when you ship one, and a generic overview and entry page otherwise.

Shipping your own page templates​

With templates your app brings its own design for the overview and the entry page, so a merchant gets proper pages on any theme, not just on a theme built for your app.

"templates": {
"archive": "cinemas.twig",
"entry": "cinema.twig"
}
  • The template is a fragment, not a whole page: the storefront renders it and shows it inside the theme's page.twig, where {{ sitepack_content() }} prints it (the theme can also print it itself as post_type_html). Header, footer and page width stay the theme's.
  • It renders in your app's own sandbox, like your blocks: the same allow-list, your own translations/, and these variables: posts (archive) or post (entry), post_type, app, settings (the merchant's settings, translated), config (your app.json) and site. sitepack_post_slug() keeps the page's language.
  • A theme's own posts-<key>.twig / post-<key>.twig always wins: a theme designed for your post type knows best.
  • A template that fails to render is logged and skipped; the page then falls back to the theme, so your app can never break the page.
  • Style it from a stylesheet your app registers with styles (a file under assets/css/, loaded in the page head), not with inline <style>. Prefix your class names with your app, so they do not clash with the theme.
"styles": ["cinemas.css"]
{# templates/cinema.twig, styled by assets/css/cinemas.css #}
<article class="cinema-app-detail">
<p><a href="{{ sitepack_post_slug(post_type.slug) }}">{{ 'cinemas.back'|trans }}</a></p>
<h1>{{ post.title }}</h1>
<p>{{ post.fields.address }}, {{ post.fields.city }}</p>
{% if post.fields.description %}<p>{{ post.fields.description|nl2br }}</p>{% endif %}
</article>

Sharing a slug​

Several public post types can use the same slug when only one of them has an overview page; the others set "archive": false. The storefront then looks /<slug>/<entry-slug> up in each post type on that slug in turn, in the order of your post_types list, and shows the first entry it finds. /<slug> is the overview of the post type that has one.

A cinema network keeps its cinemas at /cinemas/grand-central and adds landing pages per city at /cinemas/utrecht:

"post_types": [
{"key": "cinemas", "name": "Cinemas", "public": true, "slug": "cinemas", "fields": [...]},
{"key": "cinema-cities", "name": "Cinemas by city", "public": true, "slug": "cinemas", "archive": false,
"templates": {"entry": "city.twig"}, "fields": [{"key": "city", "type": "text", "label": "City", "required": true}]}
]

Put the post type whose entries should win first. An entry slug both post types have resolves to the first one, so pick slugs that cannot clash (cinema names and city names rarely do). The review checklist reports two post types on one slug that both want the overview, and an archive template on a post type with "archive": false.

SEO per entry​

Every entry of a public post type has the same SEO setup as a content page: a focus keyword, a title tag, a meta description and exclude from search engines. The merchant sets them in the entry form, which also shows the SEO score with its advice and a search result preview, and sees the score per entry in the table. They import from CSV (seo_title, seo_description, focus_keyword, no_index) and translate like the rest of the entry.

The storefront uses the title tag for <title>, the meta description for the meta and Open Graph description, and renders noindex for an excluded entry; empty texts fall back to the entry title and its first textarea field. Templates read them as post.seo.title, post.seo.description (null when not set) and post.seo.no_index.

The score counts the entry title as its heading and your text and textarea fields as its body text, so give a post type with its own pages enough free text for the merchant to write in.

SEO of the overview page​

The overview at /<slug> has SEO settings of its own: a focus keyword, a title tag, a meta description and exclude from search engines. The merchant sets them with the Settings button on the post type's page in the admin, translates them per language there or under Content → Translations (listed with the entries, under the post type's name), and an AI assistant or app sets them through the API and MCP.

The storefront uses them on the overview page the same way as on an entry page: the title tag for <title> (else the post type name), the meta description for the meta and Open Graph description (else the site's own description), and noindex for an excluded overview, which also leaves the sitemap. Only a post type with an overview page has these settings: a private post type and one with "archive": false have none. When the merchant has a page on the same slug, that page is shown with its own SEO.

Your posts-<key>.twig or archive template needs no changes: title and seo.metaDescription already carry the merchant's texts.

Sitemap​

The site's XML sitemap lists the pages of every public post type: the overview at /<slug> (unless the post type has "archive": false) and each visible entry at /<slug>/<entry-slug>, in every language the site is translated into. An entry excluded from search engines is left out, so is an overview the merchant excluded, and so is every entry of a private post type. The values of your image fields are listed as the entry page's images.

The storefront search finds the visible entries of every public post type, next to products, categories and pages. An entry matches on its title, its SEO description and the values of its text, textarea and options fields; URLs, images, numbers, dates and flags are not searched. A result links to the entry's page and shows its SEO description (or its first text) and its first image. Entries excluded from search engines and entries of a private post type are never a result.

Two rules decide who owns an address:

  • An entry wins over a page. /cinemas/grand-central shows the entry when it exists; otherwise the storefront looks for a page there as usual.
  • A page wins over the overview. When the merchant has a page with the slug cinemas, that page is shown at /cinemas. Use this to build a richer landing page with the page editor and list the entries on it with sitepack_posts().

An entry page gets the SEO title (else the entry title) as page title, the SEO description (else the first filled textarea field) as meta description and the first image field as social sharing image.

Managing entries through the API and MCP​

The entries of a post type can also be managed from outside the admin: by an app through the public API, and by an AI assistant through the SitePack MCP connector. Both see every post type the site's installed apps add, not only your own, and both use the same checks as the admin form.

ScopeAllows
posts:readReading the post types, their fields and their entries.
posts:writeCreating, changing and deleting entries, and changing the SEO of the overview page (implies posts:read).

The post types themselves and their fields come from app.json, so they are read only here: only the entries and the overview page's SEO change.

Endpoints​

GET /api/public/v1/post-types
GET /api/public/v1/post-types/{postType}
GET /api/public/v1/post-types/{postType}/archive
PATCH /api/public/v1/post-types/{postType}/archive
GET /api/public/v1/post-types/{postType}/entries?page=1&limit=25&q=arnhem
POST /api/public/v1/post-types/{postType}/entries
GET /api/public/v1/post-types/{postType}/entries/{uuid}
PATCH /api/public/v1/post-types/{postType}/entries/{uuid}
DELETE /api/public/v1/post-types/{postType}/entries/{uuid}

{postType} is the post type key, for example cinemas.

The field schema​

Every post type comes back with its fields and a fields_schema: a JSON Schema of the fields object an entry takes. It says which fields are required, the type of each value, the allowed values of an options field and the format of a date. Read it before writing an entry, so you send what the post type accepts.

{
"post_type": {
"key": "cinemas",
"name": "Cinemas",
"singular_name": "Cinema",
"public": true,
"slug": "cinemas",
"sort": "title",
"entries_count": 12,
"fields": [
{"key": "city", "type": "text", "label": "City", "required": true, "help": null, "options": []},
{"key": "country", "type": "options", "label": "Country", "required": false, "help": null, "options": [
{"value": "nl", "label": "The Netherlands"},
{"value": "be", "label": "Belgium"}
]},
{"key": "screens", "type": "number", "label": "Screens", "required": false, "help": null, "options": []}
],
"fields_schema": {
"type": "object",
"properties": {
"city": {"field_type": "text", "description": "City", "type": "string", "maxLength": 500},
"country": {"field_type": "options", "description": "Country", "type": ["string", "null"], "enum": ["nl", "be", null]},
"screens": {"field_type": "number", "description": "Screens", "type": ["number", "null"]}
},
"required": ["city"],
"additionalProperties": false
},
"archive": {
"post_type": "cinemas",
"url": "/cinemas",
"seo_title": "All our cinemas in the Netherlands",
"seo_description": "Find a cinema near you: twelve locations, from Arnhem to Utrecht.",
"focus_keyword": "cinemas",
"no_index": false,
"modified": "2026-10-07T10:15:00+02:00"
}
}
}

archive holds the SEO of the overview page, and is null for a post type without one.

Labels are returned in the site's own language, from your app's translations.

Creating and changing entries​

POST /api/public/v1/post-types/cinemas/entries

{
"title": "Grand Central",
"fields": {"city": "Utrecht", "country": "nl", "screens": 9},
"visible": true,
"seo_title": "Grand Central cinema in Utrecht"
}
  • title is required; the slug is made from it when you leave it out, and kept unique within the post type.
  • New entries are visible unless you send "visible": false.
  • PATCH changes only what you send. A field you leave out of fields keeps its value, a field sent as null is cleared, and the title and slug stay unless you send them (a new title does not change the slug).
  • An image field takes the URL of an image in the site's media library. Upload one first through the media endpoints.
  • Values are checked like in the admin. A missing required field, a wrong type or an unknown option answers 422 with the field's label in the message; a field key the post type does not have answers 422 with the keys it does have. An unknown post type answers 404 and lists the post types the site has.

The overview page​

PATCH /api/public/v1/post-types/cinemas/archive

{
"seo_title": "All our cinemas in the Netherlands",
"seo_description": "Find a cinema near you: twelve locations, from Arnhem to Utrecht.",
"focus_keyword": "cinemas"
}
  • Only what you send changes; an empty string clears a text, and the storefront then falls back to the post type name and the site's description. Send "no_index": true to keep the overview out of search engines and the sitemap.
  • Texts are made one line; seo_title and focus_keyword keep at most 190 characters, seo_description 320.
  • A post type without an overview page (private, or "archive": false) answers 422, and so does a request that sends none of the four properties.
  • The texts are written in the site's main language. Translations are made in the admin.

MCP tools​

The SitePack MCP connector for Claude offers the same actions as tools, so a merchant can say "add the new cinema in Arnhem with 9 screens" and the assistant knows which fields to fill:

ToolWhat it does
list_post_typesThe site's post types with their fields and fields_schema.
get_post_typeOne post type by key, with the SEO of its overview page under archive.
get_post_type_archiveThe SEO of a post type's overview page.
update_post_type_archiveChange the SEO of the overview page (seo_title, seo_description, focus_keyword, no_index); only what is passed changes.
list_post_entriesThe entries of a post type, paged, with an optional search.
get_post_entryOne entry by uuid.
create_post_entryCreate an entry (post_type, title, fields, ...).
update_post_entryChange an entry; only what is passed changes.
delete_post_entryDelete an entry.

Translations​

On a site with extra languages the merchant translates each entry, in the entry form (a language picker at the top) or under Content → Translations, where each post type is its own content type, listed by its name (for example Cinemas), and its entries can be auto-translated like any other content.

  • Translated are the title and every text and textarea field. Numbers, images, URLs, email addresses, dates, switches and options are the same in every language.
  • The slug is not translated: a post type's addresses are the same in every language, only the language prefix differs (/de/cinemas/grand-central).
  • sitepack_posts(), sitepack_post() and the post type pages return the entries in the page's language: a translated title or field replaces the original, anything untranslated keeps the original. Your templates need no changes.
  • The SEO texts of the overview page (title tag, meta description, focus keyword) are translated the same way, with the Settings button on the post type's page or under Content → Translations.
  • Entries and overview pages are also translatable through the public translations API and the MCP translation tools with type post: an entry by the uuid from list_post_entries, an overview page by the uuid from translation_review_queue with type: post. See AI editor (MCP).
  • Field labels in the admin follow your app's translations, as everywhere else.

Review checks​

The app review checklist validates post_types: a malformed key, a reserved slug, an unknown field type, a duplicate or reserved field key, an options field without options, or a templates entry that is not a .twig file inside templates/ or is missing from your app blocks the review, with the reason. At runtime such a declaration is left out silently, so fix what the checklist reports.

Next Steps​