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()andsitepack_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/cinemasand/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."}
]
}
]
}
| Key | Required | Description |
|---|---|---|
key | yes | How 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. |
name | yes | The plural label, shown under Content in the admin sidebar and as the page title of the overview. |
singular_name | no | The label of one entry ("Add Cinema"). Defaults to name. |
public | no | true gives the post type storefront routes. Default false (private). |
slug | no | The first path segment of a public post type. Defaults to key. Same format as key. |
icon | no | A Font Awesome class for the admin menu, such as "fas fa-film". Defaults to a document icon. |
archive | no | Public 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. |
templates | no | Public 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. |
sort | no | The 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). |
fields | no | The 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
type | Admin input | Value in Twig |
|---|---|---|
text | One-line text | string |
textarea | Multi-line text | string (print with nl2br) |
number | Number | integer or float |
image | Media library picker | absolute CDN URL of the image |
url | Text | a https:// address or a path on the site (/contact) |
email | string | |
date | Date picker | YYYY-MM-DD |
boolean | Switch | true / false |
options | Select | the 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 }} · {{ 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:
| Property | Description |
|---|---|
uuid | Stable id of the entry. |
type | The post type key. |
title / slug | The entry's title and slug. |
url | The 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. |
position | The number used by sort: "position". |
fields | Your 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 / modified | ISO 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')andsitepack_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:
| Address | 1. Theme, for this post type | 2. Your app's template | 3. Theme, generic |
|---|---|---|---|
/<slug> (overview) | posts-<key>.twig | templates.archive, inside the theme's page.twig | posts.twig, then page.twig |
/<slug>/<entry-slug> | post-<key>.twig | templates.entry, inside the theme's page.twig | post.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 aspost_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) orpost(entry),post_type,app,settings(the merchant's settings, translated),config(your app.json) andsite.sitepack_post_slug()keeps the page's language. - A theme's own
posts-<key>.twig/post-<key>.twigalways 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 underassets/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.
Site search
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-centralshows 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 withsitepack_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.
| Scope | Allows |
|---|---|
posts:read | Reading the post types, their fields and their entries. |
posts:write | Creating, 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"
}
titleis required; theslugis made from it when you leave it out, and kept unique within the post type.- New entries are visible unless you send
"visible": false. PATCHchanges only what you send. A field you leave out offieldskeeps its value, a field sent asnullis cleared, and the title and slug stay unless you send them (a new title does not change the slug).- An
imagefield 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
422with the field's label in the message; a field key the post type does not have answers422with the keys it does have. An unknown post type answers404and 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
nameand the site's description. Send"no_index": trueto keep the overview out of search engines and the sitemap. - Texts are made one line;
seo_titleandfocus_keywordkeep at most 190 characters,seo_description320. - A post type without an overview page (private, or
"archive": false) answers422, 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:
| Tool | What it does |
|---|---|
list_post_types | The site's post types with their fields and fields_schema. |
get_post_type | One post type by key, with the SEO of its overview page under archive. |
get_post_type_archive | The SEO of a post type's overview page. |
update_post_type_archive | Change the SEO of the overview page (seo_title, seo_description, focus_keyword, no_index); only what is passed changes. |
list_post_entries | The entries of a post type, paged, with an optional search. |
get_post_entry | One entry by uuid. |
create_post_entry | Create an entry (post_type, title, fields, ...). |
update_post_entry | Change an entry; only what is passed changes. |
delete_post_entry | Delete 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
textandtextareafield. 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 fromlist_post_entries, an overview page by the uuid fromtranslation_review_queuewithtype: 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
- Translations: translate your post type labels.
- Functions reference:
sitepack_posts(),sitepack_post()andsitepack_post_slug(). - Storefront Blocks & Events: render your entries from your own app blocks.
- Scopes:
posts:readandposts:writefor managing entries through the API.