Skip to content

Configuration

One object configures the whole server. Pass it to TrokkyExpress.create() or to startServer(), directly or from a trokky.config.ts file.

import { defineConfig } from '@trokky/trokky/express'
export default defineConfig({
schemas,
storage: {
data: { adapter: 'filesystem-data', options: { contentDir: './data/content' } },
media: { adapter: 'filesystem-media', options: { mediaDir: './data/media' } },
},
})

schemas and storage are the only required keys. Everything else has a default, applied by withDefaults() before the server starts.

Defaults below marked (dev) and (prod) differ by environment. The environment is config.env, falling back to NODE_ENV, falling back to 'development'.

Some keys are read only by startServer(). If you build the Express app yourself and call TrokkyExpress.create() + mount(), these are parsed, defaulted, and then ignored:

  • server.port
  • server.trustProxy
  • server.lifecycle
  • mail
  • hooks
  • routes

There is no warning. Use startServer() if you need any of them — see Deployment shapes.

KeyDefaultWhat it does
schemasRequired. Your content schemas. See Schemas
storageRequired. Data and media adapters
envNODE_ENV, then 'development'Drives every environment-dependent default below
mediasee belowImage processing, upload limits, serving
securitysee belowAuthentication, tokens, rate limiting, hashing
serversee belowHTTP surface: paths, CORS, body parsing, static files
studiosee belowThe admin UI
i18nsee belowLocales for content and UI
featuresunsetAuto-thumbnail and auto-slug field injection
oauthunsetGoogle sign-in
oauth2unsetOAuth2 authorization server, used by CLI login and SSO
captchaunsetTurnstile, hCaptcha or reCAPTCHA on auth endpoints
mailunsetOutbound system email. startServer only
hooksunsetEvent handlers and outbound webhooks. startServer only
routesunsetCustom Express routes. startServer only

Both data and media are required, each with an adapter name and an options object. defineConfig() throws if either adapter is missing.

The adapter must also be imported for its side effect, and the per-adapter options are documented in Storage adapters.

KeyAccepted values
storage.data.adapter'filesystem-data', 'postgres-data'
storage.media.adapter'filesystem-media'

The TypeScript union also lists cloudflare-d1, dynamodb, cloudflare-r2 and s3. Those adapters are not implemented; naming one compiles and then fails at startup.

KeyDefaultWhat it does
media.processor'sharp'Image engine. 'none', 'sharp', 'cloudflare-images', 'imagekit', 'imgix'
media.variants[]Variants generated on upload
media.upload.maxFileSize52428800 (50 MB)Per-file upload limit in bytes
media.upload.maxFiles10Files per upload request
media.upload.allowedMimeTypesJPEG, PNG, WebP, SVG, MP4, WebM, PDF, plain textAccepted upload types
media.serving.mode'api''api' serves files through the API router; 'static' serves them from a static path
media.serving.staticBasePath'/media'Path prefix when mode is 'static'
media.serving.customDomainunsetDomain used when building media URLs
media.mediaUrlGeneratorunsetA function taking { _id, filename, mimeType } and returning a URL, or a config object handed to the Studio

Each entry in media.variants is:

media: {
variants: [
{ name: 'thumbnail', width: 300, height: 200, format: 'webp', quality: 80, fit: 'cover' },
{ name: 'hero', width: 1200, height: 800, format: 'webp', quality: 90, fit: 'cover' },
],
}

name is required. format is one of jpeg, png, webp, avif; fit is one of cover, contain, fill, inside, outside.

Setting media at all replaces the defaulted sub-objects it contains, so a media block that sets only processor keeps the default upload and serving values, but one that sets upload: { maxFiles: 3 } drops the default maxFileSize and allowedMimeTypes. Spell out the whole upload object when you override it.

media.upload.maxFileSize is enforced separately from server.parsing.json.limit. Raising one without the other gives you a body-parser rejection instead of a media validation error.

KeyDefaultWhat it does
security.enabledtrueMaster switch for authentication
security.jwtSecret'dev-secret-change-in-production' (dev), unset (prod)JWT signing key. Unset falls through to TROKKY_JWT_SECRET, then a random per-process secret
security.tokens.accessTokenTtl'2h'Access token lifetime
security.tokens.refreshTokenTtl'7d'Refresh token lifetime
security.tokens.rememberMeTtl'30d'Lifetime when “remember me” is used
security.validation.inputtrueValidate request bodies
security.validation.schemastrueValidate documents against their schema
security.validation.permissionstrueEnforce per-user permissions
security.rateLimit.enabledtrueRate limiting
security.rateLimit.windowMs900000 (15 min)Rate limit window
security.rateLimit.maxRequests1000 (dev), 100 (prod)Requests per window
security.rateLimit.skipSuccessfulRequestsfalseCount only failed requests
security.cryptoOptions.adapterType'auto''node' for bcrypt, 'webcrypto' for PBKDF2, 'auto' to detect
security.cryptoOptions.saltRounds12bcrypt cost factor
security.cryptoOptions.pbkdf2Iterations100000PBKDF2 iterations for the WebCrypto adapter
security.adminUserunsetAccount created at startup if absent
security.passkeyunsetWebAuthn configuration

An unset jwtSecret in production is the trap: the server starts, mints tokens with a random secret, and invalidates every session on restart. Deployment shapes explains the consequence in full.

security.adminUser requires username, email, password, firstName and lastName; role defaults to 'admin'. Creation runs on every boot and is a no-op once the user exists, so leaving it in a production config means a known password in your repository forever.

The rate limit defaults are more permissive in development on purpose. A production instance behind a proxy counts requests per the address Express resolves, which is why the proxy trust setting matters — see Auth.

KeyDefaultWhat it does
server.basePath''API base path. startServer treats an empty value as /api
server.port3000 (dev), unset (prod)Listening port. startServer only; falls back to PORT, then 3000
server.cors.origin['http://localhost:5173', 'http://localhost:3000'] (dev), false (prod)Allowed origins. Boolean, string, array, or a function
server.cors.methods['GET','POST','PUT','DELETE','OPTIONS']Allowed methods
server.cors.allowedHeaders['Content-Type', 'Authorization']Allowed request headers
server.cors.credentialstrueAllow credentialed requests
server.cors.maxAgeunsetPreflight cache duration
server.static.mediaunset{ path, directory, maxAge? } serving media from disk instead of through the API
server.static.assetsunset{ path, directory, maxAge? } serving Studio assets
server.static.custom[]Additional { path, directory, maxAge? } entries
server.parsing.json.limit'50mb'JSON body size limit
server.parsing.json.strictfalseExpress strict JSON parsing
server.parsing.urlencoded.limit'50mb'Form body size limit
server.parsing.urlencoded.extendedtrueExpress extended urlencoded parsing
server.trustProxyunsetPassed to app.set('trust proxy', …). startServer only
server.lifecycleunsetStartup and shutdown hooks. startServer only

server.trustProxy being startServer-only has a wrinkle worth knowing: mount() calls app.set('trust proxy', 1) unconditionally, so a manually mounted app already trusts the first hop whatever you configured.

server.lifecycle takes four optional functions:

server: {
lifecycle: {
beforeStart: async (app) => { app.use(myMiddleware) },
afterStart: async (app, port) => { console.log(`up on ${port}`) },
beforeShutdown: async () => { await drainQueue() },
onError: async (error) => { report(error) },
},
}

beforeStart runs after the Express app is created and before Trokky mounts, which is the only place to install middleware that must sit in front of the API router.

KeyDefaultWhat it does
studio.enabledtrueServe the admin UI
studio.path'/studio'Mount path. mount()’s studioPath option overrides it
studio.apiUrlunsetAbsolute API URL for the Studio, for cross-origin setups
studio.requireAuthtrueRequire sign-in
studio.branding.title'Trokky CMS'Title shown in the UI
studio.branding.logounsetLogo URL
studio.branding.theme'system''light', 'dark' or 'system'
studio.branding.colors.primaryunsetPrimary accent colour
studio.branding.colors.accentunsetSecondary accent colour
studio.structureunsetNavigation structure. See Singletons for what it does and does not control
studio.fields[]Custom field type registrations
studio.settings.pageSize20Documents per page in list views
studio.settings.enableDraftstrueDraft workflow. See Drafts
studio.settings.enableVersioningfalseDocument versioning
studio.settings.autoSavetrueAutosave while editing
studio.settings.autosaveInterval30000Autosave interval in milliseconds
studio.session.refreshBuffer300000 (5 min)Refresh the token this long before expiry
studio.session.warningBuffer600000 (10 min)Warn the user this long before expiry
studio.session.checkInterval30000How often session validity is checked
studio.session.inactivityTimeout1800000 (30 min)Sign out after this much inactivity

refreshBuffer must stay below security.tokens.accessTokenTtl or the token expires before the refresh fires. With the defaults — 5 minutes against 2 hours — there is plenty of room; shortening accessTokenTtl to anything near 5 minutes removes it.

KeyDefaultWhat it does
i18n.defaultLocale'en'Default locale for content and UI
i18n.supportedLocales['en', 'fr']Locales offered
i18n.fallbackLocale'en'Used when a translation is missing
i18n.detectBrowserLanguagetrueDetect locale from the browser
i18n.debugtrue (dev), false (prod)Log missing translations

The supportedLocales default includes fr. If you want English only, say so — leaving it unset offers French in the Studio’s language switcher.

Both blocks inject fields into your schemas at load time.

KeyDefaultWhat it does
features.autoThumbnail.enabledunsetInject a thumbnail field into document schemas
features.autoThumbnail.fieldNameunsetName of the injected field
features.autoThumbnail.skipSingletonsunsetSkip singleton schemas
features.autoThumbnail.skipSchemasunsetSchema names to skip
features.autoThumbnail.maxFileSizeunsetSize limit for thumbnail uploads
features.autoThumbnail.allowedTypesunsetMIME types accepted for thumbnails
features.autoSlug.enabledunsetInject a slug field
features.autoSlug.sourceFieldsunsetFields to derive the slug from, in priority order
features.autoSlug.uniqueunsetEnforce slug uniqueness

withDefaults() does not fill this block in — it is passed through as written, so an omitted features key means whatever the schema registry does on its own. Set the values you care about explicitly rather than relying on a documented default; the working demo config sets autoThumbnail.fieldName to '_thumbnail', autoSlug.sourceFields to ['title', 'name'], and both enabled flags to true.

Injected fields show up in generated types like any other field.

KeyWhat it does
oauth.google.clientIdGoogle OAuth client id
oauth.google.clientSecretGoogle OAuth client secret
oauth.google.redirectUriRedirect URI registered with Google
oauth2.enabledRun Trokky as an OAuth2 authorization server
oauth2.issuerIssuer identifier
oauth2.accessTokenTtlAccess token lifetime in seconds
oauth2.refreshTokenTtlRefresh token lifetime in seconds
oauth2.deviceCodeTtlDevice code lifetime in seconds
oauth2.authCodeTtlAuthorization code lifetime in seconds
oauth2.pollingIntervalDevice flow polling interval
oauth2.clientsRegistered clients: { id, name, description?, type?, secret?, redirectUris, allowedScopes?, grantTypes? }
captcha.provider'turnstile', 'hcaptcha' or 'recaptcha'
captcha.siteKeyPublic site key
captcha.secretKeyServer-side secret
captcha.options.theme'light', 'dark' or 'auto'
captcha.options.size'normal', 'compact' or 'invisible'
captcha.options.languageWidget language; defaults to i18n.defaultLocale or 'auto'
captcha.protectedEndpoints.loginRequire a CAPTCHA on sign-in
captcha.protectedEndpoints.passwordResetRequestRequire one on reset request
captcha.protectedEndpoints.passwordResetVerifyRequire one on reset verification

None of the three has defaults. oauth2 is what trokky login uses for the device flow — see The CLI.

Guard these blocks on the credentials being present, so a missing environment variable disables the feature rather than half-configuring it:

oauth: process.env.GOOGLE_CLIENT_ID
? {
google: {
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
redirectUri: process.env.GOOGLE_REDIRECT_URI || '',
},
}
: undefined,

Read by startServer() only.

KeyDefaultWhat it does
mail.adapterRequired within the block. A MailAdapter instance
mail.templateRendererbuilt-in rendererCustom email template renderer
mail.defaultFrom'noreply@localhost'Sender address
mail.defaultFromNameunsetSender display name
mail.notifications.passwordResetunsetEmail on password reset request
mail.notifications.passwordChangedunsetEmail when a password changes
mail.notifications.userCreatedunsetWelcome email on user creation
mail.notifications.userInvitedunsetInvitation email
mail.notifications.securityAlertsunsetSecurity alerts
mail.debugtrue (dev), false (prod)Log mail operations

Links inside those emails are built from STUDIO_URL, falling back to http://localhost:<port>. Set it, or your editors receive password reset links pointing at localhost.

Adapters ship at @trokky/trokky/mail/console, @trokky/trokky/mail/resend and @trokky/trokky/mail/smtp. resend and nodemailer are optional dependencies of @trokky/trokky.

Read by startServer() only. Each key takes a handler receiving an event object.

KeyEvent payload
document.createdDocumentEvent
document.updatedDocumentEvent
document.deletedDocumentEvent
document.publishedDocumentEvent
document.unpublishedDocumentEvent
user.createdUserEvent
user.updatedUserEvent
user.deletedUserEvent
user.loginUserEvent
user.logoutUserEvent
media.uploadedDocumentEvent
media.deletedDocumentEvent
webhooksAn array of WebhookConfig

A DocumentEvent carries type, collection, document, previousDocument on updates, user when there is one, and timestamp. A UserEvent carries type, user, metadata and timestamp.

hooks: {
'document.published': async (event) => {
await fetch('https://example.com/rebuild', { method: 'POST' })
},
webhooks: [
{
url: 'https://example.com/hooks/trokky',
events: ['document.published'],
secret: process.env.WEBHOOK_SECRET,
retry: { maxAttempts: 3, initialDelay: 1000, backoffMultiplier: 2 },
},
],
}

Handlers can return a promise. WebhookConfig takes url and events as required, plus optional secret, headers and retry.

Read by startServer() only. An array of routes, groups, or both.

A route:

KeyDefaultWhat it does
pathExpress-style path
methodGET, POST, PUT, DELETE or PATCH
handler(req, res, next) => …
middlewareunsetMiddleware for this route
auth'public'true for any signed-in user, 'admin' for admins, 'public' for none
descriptionunsetDocumentation only

A group wraps routes under a shared prefix, with optional middleware and auth applied to all of them:

routes: [
{
prefix: '/api/forms',
auth: 'public',
routes: [
{ path: '/contact', method: 'POST', handler: contactHandler },
],
},
]

Custom routes are mounted after Trokky’s own routers. Express matches in registration order, so a custom path that falls under the API or Studio mount is shadowed by them and your handler never runs.

defineConfig() checks four things and throws on each: at least one schema, a storage block, a storage.data.adapter, and a storage.media.adapter. It also throws when env is production and security.jwtSecret is missing.

It runs only when you call it. Wrapping your config in defineConfig() is how you get those five checks; TrokkyExpress.create() and startServer() do not run them for you.