Things that will bite you
Every system has behaviour that is correct, documented somewhere, and still surprising the first time you meet it. This page collects Trokky’s.
Nothing here is a bug. These are all things Trokky does on purpose, in situations where the reasonable expectation is something else.
A singleton must say so in its schema
Section titled “A singleton must say so in its schema”If a collection holds exactly one document — a homepage, a settings record, a contact page — its schema must declare it:
export const homepageSchema = { name: 'homepage', type: 'document', singleton: true, // this line fields: { /* ... */ },}Declaring it in structure.ts is not enough:
// This controls navigation. It does NOT make the collection a singleton.{ type: 'singleton', schemaType: 'homepage', documentId: 'home' }The two answer different questions. The schema decides whether the collection holds one document — that governs the create guard, upserts, and how backups are restored. The structure entry only decides which document the Studio’s navigation opens.
Get this wrong and everything looks fine. The Studio renders correctly, the site works, and nothing complains. Then you restore a backup: trokky restore uses an id-preserving upsert only for schema-level singletons, so a collection missing the flag gets a freshly generated id instead. Your structure.ts now points at a document that does not exist, and the second document in the collection is rejected outright.
On one real site this cost two documents out of fifty and every singleton id.
Since 2.0.1 the server refuses to start when the structure claims a singleton the schema does not declare, naming each offending collection. If you are on an older version, check by hand.
One more spelling trap: isSingleton: true is a structure key. On a schema it is silently ignored — schemas are validated with unknown keys stripped, so there is no error and no warning. It has to be singleton.
Astro inlines import.meta.env at build time
Section titled “Astro inlines import.meta.env at build time”If your frontend reads configuration like this:
const apiUrl = import.meta.env.TROKKY_API_URLAstro replaces that expression with a literal value when the site is built, not when it runs. Setting the variable on your server process does nothing; the value was baked in already.
The symptom is specific and confusing: pages render fine, and every image 404s. The media proxy is usually the one file reading the variable this way, so it is the only thing that breaks.
Set TROKKY_API_URL for the build:
TROKKY_API_URL=https://cms.example.com/api npm run buildCompare with a runtime read, which behaves as you would expect:
const apiUrl = process.env.TROKKY_API_URL ?? 'http://localhost:3000/api'Both forms appear in real projects, sometimes in the same repository. When something is configured and still wrong, check which one you are looking at.
A missing JWT secret invents one, silently
Section titled “A missing JWT secret invents one, silently”The signing key is resolved as security.jwtSecret, then TROKKY_JWT_SECRET, then a freshly generated random secret. That last branch logs nothing and throws nothing.
A production deploy with neither set will start, sign tokens, and work. It keeps working until the process restarts — at which point every session and every issued token becomes invalid, because the new process generated a different key. Behind a load balancer with more than one replica it is worse and stranger: a token minted by one replica is rejected by the others, so sign-ins fail intermittently and unreproducibly.
Set it explicitly:
security: { jwtSecret: process.env.TROKKY_JWT_SECRET,}defineConfig() throws when env is production and no secret is set, which catches this — but only if you actually wrap your config in it. Neither TrokkyExpress.create() nor startServer() calls it for you.
Restore preserves ids for singletons and regenerates them for everything else
Section titled “Restore preserves ids for singletons and regenerates them for everything else”trokky restore is not a byte-for-byte copy.
- Singletons keep their document id, so a structure entry that names
documentId: 'home'still resolves. - Everything else gets a new id on restore, and references between documents are rewritten to match.
For list collections this is fine and intended. It stops being fine if anything outside the CMS refers to a document by id — a hardcoded link, an external system, a redirect map. Address documents by slug if you need them to survive a restore.
An update omits what it does not send
Section titled “An update omits what it does not send”When the Studio saves an existing document, fields the editor never touched are left out of the request, and the server merges over what it already has. A field missing from an update means “unchanged”, not “clear it”.
To actually clear a field, send null. An empty array clears an array field.
This matters when you write to the API yourself: a PUT with a partial body is a partial update, not a replacement.
Media URL transforms are not applied by every deployment
Section titled “Media URL transforms are not applied by every deployment”Requesting ?w=800&fm=webp returns a transformed image only where an image processing pipeline is available. Where it is not, you get the original file back with a 200 and the original content type.
That is a silent difference, not an error. If you are debugging why images are larger than expected, check whether the transform is happening at all before looking at your query parameters.
Adapters are chosen by importing them
Section titled “Adapters are chosen by importing them”import '@trokky/trokky/adapters/filesystem-data'import '@trokky/trokky/adapters/postgres-data'These have no exports you use. Importing one registers it; the configuration then names which registered adapter to use. Remove the import and the config still names it, but nothing registered it, so startup fails on a missing adapter rather than on a missing import.
Bundlers that aggressively drop “unused” imports will break this. If your build strips side-effect imports, adapters are the thing that stops working.
Auth failures and permission failures are different
Section titled “Auth failures and permission failures are different”Since 2.0.1:
- 401 — you are not authenticated. Missing, invalid or expired token.
- 403 — you are authenticated, but not allowed to do this.
- 400 — the request itself is malformed.
Before 2.0.1 all three came back as 400. If you wrote a client that treats 400 as “not signed in”, it will misbehave after upgrading — and if you treat 401 as “session expired” and refresh on it, make sure you are not refreshing on a permission denial. That distinction is why the split matters.
Content lives where you put it, including in git
Section titled “Content lives where you put it, including in git”With the filesystem adapter, documents are JSON files under your data directory and media are real files on disk. That is a feature — it is what makes content reviewable and portable.
It also means two things people forget. Committing your data directory commits your content, including anything an editor uploaded. And a deployment platform with an ephemeral filesystem will discard everything on the next deploy unless the data directory is on a persistent volume.
For anything with more than one editor or more than a little content, use the Postgres adapter for data and keep media on a persistent volume or object store.