Using Polaris
Concepts
Posts, statuses and scheduling without cron, languages and translation pairs, categories, pillars, media and backups in Polaris CMS.
Posts#
A post is one row in the articles table. The admin editor and the API's save action accept the same fields, and the API's get action returns them all. The full list with limits is in the API reference; the ones that shape how a post behaves:
| Field | What it does |
|---|---|
title |
Required, up to 200 characters. The page's only h1. |
slug |
Optional. Derived from the title when missing, transliterated (Żółta łódź becomes zolta-lodz), up to 120 characters. Unique per language. |
content |
The Markdown body. See The editor. |
excerpt |
Card and list text, up to 600 characters. |
metaDescription |
The search snippet. Falls back to the excerpt. |
focusKeyword |
Drives the SEO score. |
category, categories |
Category keys defined in code. The first is the primary category. |
status, publishedAt |
Lifecycle, below. |
lang, translationKey |
Newest version only. Below. |
The URL of a post comes from its slug and language, for example /blog/<slug>/ for English and /pl/blog/<slug>/ for Polish on scentro.pl. The blog paths are set in the site's config.
Statuses#
Every post is draft, scheduled or published.
- draft: never public. Not in listings, feeds, the sitemap or the
listAPI action. - scheduled: needs a
publishedAttime. The post is public from that moment. - published: public now. If
publishedAtis missing it is set to the current time; send one to backdate a post.
Saving a post as scheduled with a time that has already passed stores it as published.
Scheduling without cron#
A scheduled post does not wait for a job to flip a flag. Every public query (listings, the post page, RSS, the sitemap) treats a post as live when it is published, or scheduled with a publishedAt that has passed. So the post appears at its time even if nothing else runs.
Two pieces of housekeeping still happen around it: changing the stored status to published (which also sends the IndexNow ping for the new URL) and taking the daily full backup.
- Newest Astro version: the app does both on its own. Server requests trigger a background tick at most every 10 minutes per process, and it never delays the request.
GET /api/cron/dailywithAuthorization: Bearer $CRON_SECRETforces the same work if you want a scheduled task anyway. - Kit versions: schedule
GET /api/cron/dailyonce a day (any cron, a Coolify scheduled task, an uptime pinger). Without it, posts still go live on time; they just keep thescheduledstatus, get no IndexNow ping and there is no nightly full backup.
Languages and translation pairs#
Newest Astro version. Every post has a lang: en by default, or pl on a two-language site. Slugs are unique per language, so /blog/astro-tips/ and /pl/blog/astro-tips/ can both exist.
A post and its translation share a translationKey. There are three ways to pair them:
- in the admin, pick the other-language post under Language → Translation;
- through the API, send
translationOf: <id of the other post>. Its key is reused, or created from its slug and written back to it; - set
translationKeydirectly. An empty string""removes the pairing.
Only one post per language can hold a key: a second English post with the same key is refused with 409.
hreflang links are emitted only when the translation is live. A pair where one side is still a draft shows no alternates, so search engines are never pointed at a page that doesn't exist yet. Link Genius suggests links in the post's own language.
The kit versions are single-language: no lang, no translationKey, and slugs are unique across the site.
Categories#
Categories are defined in code (CATEGORIES in src/cms/journal/config.ts), with a label, a short blurb and SEO texts per language. Add one there and it appears in the editor and on the site. A post can have several; the first is its primary category and its URL group. Keys the config doesn't know are dropped on save, and a post with none gets the default category.
Listing pages (the blog index and category pages) with fewer than 3 live posts are noindex and stay out of the sitemap, so thin pages don't get indexed. They become indexable on their own as posts go live.
Pillars, clusters and intent#
Each post has an intent (informational, commercial, transactional or navigational; informational by default) and can be marked as a pillar. A non-pillar post joins a pillar's cluster:
- when it names the pillar in Belongs to pillar (
pillarId), and it is flagged if it doesn't actually link to it; - otherwise when it links to the pillar;
- otherwise when it scores above 9 against a pillar: +10 for the same primary category, +2 per shared tag, +1 each way when one post's focus keyword appears in the other's title.
The admin's Visualizer shows these clusters per language, unlinked posts first. Marking a post as a pillar clears its own pillarId; deleting a pillar sends its cluster back to automatic clustering.
Media#
Uploads go through the admin's media library or POST /api/cms/upload. Every image is auto-rotated, fitted inside 1600 × 1600 px, converted to WebP and stored as bytes in the media table, so there is no bucket or volume to configure and a database backup includes the images.
- Accepted: JPEG, PNG, WebP, AVIF, GIF, HEIC/HEIF, TIFF, up to 12 MB. The newest version also takes SVG, rasterised at 2× to lossless WebP and never served as SVG, so an uploaded file can't carry script onto your domain.
- Quality: WebP 80, PNG sources at 90 in the newest version (screenshots and exported charts keep crisp edges).
- Served from
/media/<uuid>/<name>.webpwithCache-Control: public, max-age=31536000, immutable. Ids are random and content never changes. - The post renderer adds the real width and height to every uploaded image it shows, so there is no layout shift.
Backups#
Backups are snapshots of posts stored in the article_backups table:
| Kind | When | Kept |
|---|---|---|
auto |
Before every save and status change, the previous version of the post | 30 days |
pre-delete |
Before a post is deleted | Until you delete it |
scheduled |
A full snapshot of every post, once a day | Until you delete it |
manual |
A full snapshot from Admin → Backups or the API | Until you delete it |
Admin → Backups lists them, downloads any snapshot as JSON and restores it in place. A restore upserts by language and slug, so posts created after the snapshot are left alone. These backups cover posts only and live in the same database: keep regular PostgreSQL backups as well, for media and keys.
API keys and sessions#
Two ways in. The admin signs in at /studio with ADMIN_USERNAME and ADMIN_PASSWORD and gets a session cookie. Scripts and agents use API keys created in Admin → API Keys: a key is shown once, stored as a SHA-256 hash, and can read and write posts, media and backups, but can't create or revoke keys. Details in Security and Using with AI agents.
Open source under MIT. Public release on GitHub and npm in preparation. Roadmap.