Skip to content

Get started

Configuration

starter.config.ts(Licensees only) decides which parts of the kit are on, which providers each part bundles, and your plans, roles, permissions and languages. You edit it whenever you change one of those. Secrets and values that differ between deploys go in environment variables instead.

How it works

  1. defineStarterConfig returns the object unchanged. It exists for its types: the literals you write become unions such as PlanId, UserRole, Permission and FlagId in shared/types/config.ts(Licensees only), so a misspelled plan or permission fails typecheck. The shape is StarterConfig in shared/types/starter.ts(Licensees only).
  2. nuxt.config.ts loads the file at build time. build/assertions.ts(Licensees only) checks it against wrangler.jsonc and i18n/locales/, and the registries in modules/ check providers, plans and roles. Every failure names the key and the fix.
  3. Only the providers you list are bundled. build/runtime-config.ts(Licensees only) declares a runtime-config slot for each credential those providers read, and NUXT_* environment variables fill the slots.
  4. When a deployed Worker boots, server/plugins/env-check.ts(Licensees only) logs one [env] warning naming every variable an enabled part needs and the deploy lacks. It stays quiet under pnpm dev.

Read the config in code

Import the file directly. It works the same in server routes, components and composables:

import starter from '~~/starter.config'

export const sellsYearly = Object.values(starter.payments.plans).some(
  (plan) => plan.mode === 'subscription' && 'year' in plan.intervals,
)

export function isTopPlan(plan: PlanId): boolean {
  return plan === 'promax'
}

PlanId and the other unions from shared/types/config.ts are auto-imported on both sides. Because PlanId is 'pro' | 'promax' | 'lifetime', comparing it with 'proMax' fails typecheck.

Change it

Save the file and pnpm dev restarts, because nuxt.config.ts watches it.

Keep it pure data. Nuxt, Vite, @nuxt/content, Vitest and the seed scripts all load it, and the seed scripts' loader (scripts/seed/lib/starter-config.mjs(Licensees only)) refuses any import other than #starter/define. A function or a second import breaks at least one of them. The #starter/* entry under imports in package.json exists for that one import, so leave it there.

The build refuses a config that can't work:

  • build/assertions.ts(Licensees only): a declared locale with no file in i18n/locales/, or a file there that no locale declares; no HYPERDRIVE binding; cloudflare email without a send_email binding named EMAIL; the queue on without its producer, consumer and dead letter consumer; workersai without the AI binding; a storage purpose whose binding or bucket isn't in r2_buckets; a deletion grace period with auth on and email off; notifications with auth off; a firstAccountRole that doesn't grant users:manage.
  • modules/tenancy-assert.ts(Licensees only): an org permission that a plan sells, more than two orgs.levels, an ownerRole that can't be held at the root, invitations with email off, organizations with auth off.
  • modules/payments-registry.ts(Licensees only): the plan rules on Payments, such as a plan id outside /^[a-z][a-z0-9]*$/ or trialDays with Creem or Lemon Squeezy enabled.

Set environment variables

Nitro fills each runtime-config key from NUXT_ plus the key's path in upper snake case:

Runtime config keyVariable
payments.stripe.secretKeyNUXT_PAYMENTS_STRIPE_SECRET_KEY
payments.stripe.refs.pro_monthNUXT_PAYMENTS_STRIPE_REFS_PRO_MONTH
ai.workersai.models.copyNUXT_AI_WORKERSAI_MODELS_COPY
public.siteUrlNUXT_PUBLIC_SITE_URL

Locally they go in .env. .env.example(Licensees only) lists every variable the kit reads and where each value comes from. On a deploy, set them on the Worker. The Worker reads them per request, so changing one needs no rebuild:

pnpm exec wrangler secret put NUXT_PAYMENTS_STRIPE_SECRET_KEY --name <worker>

A few are read at build time and baked into the bundle: NUXT_SITE_URL, NUXT_PUBLIC_TURNSTILE_SITE_KEY and NUXT_IMAGE_PROVIDER. For pnpm deploy:cf those go in .env.production (Deployment).

Each topic page lists its own variables. These belong to the app as a whole:

VariableRead atWhat it does
NUXT_SITE_URLbuildThe canonical origin, used for SEO tags, the sitemap and every mailed link.
NUXT_PUBLIC_SITE_URLruntimeOverrides NUXT_SITE_URL on a deploy without a rebuild.
NUXT_SESSION_PASSWORDruntimeEncrypts the session cookie. 32+ characters. The first pnpm dev writes a random one to .env.
NUXT_SESSION_COOKIE_SECUREruntimefalse drops the cookie's Secure flag, for a production build served over http such as pnpm preview.
NUXT_CONTACT_ADDRESSruntimeWhere /contact sends messages, and the address it shows after a Turnstile check. Never sent to a page (Contact).
NUXT_BLOG_AUTHOR_EMAILruntimeThe account every blog post is bylined with (Blog).
NUXT_OG_IMAGE_SECRETruntimeSigns OG image URLs. Unset, each build signs with its own secret.
NUXT_PUBLIC_TURNSTILE_SITE_KEYbuildTurnstile's site key. Left out of .env, pnpm dev uses Cloudflare's always-pass test keys.
NUXT_TURNSTILE_SECRET_KEYruntimeTurnstile's secret key, the pair to the site key.
NUXT_IMAGE_PROVIDERbuildcloudflare turns on image optimization. Unset, images are served as they are.
NUXT_DEV_TUNNEL_HOSTSdevComma-separated hosts the dev server accepts besides *.trycloudflare.com.
NUXT_DEV_REMOTE_AIdevtrue binds Workers AI in pnpm dev, after pnpm exec wrangler login.
NUXT_DEV_PGLITE_PORTdevPins the port pnpm dev serves PGlite on. Unset, it takes the first free one from 5432 to 5441.
DATABASE_URLdevA Postgres for pnpm dev in place of PGlite (Database).

Every key

Defaults are the shipped values. The linked page covers each part in full.

KeyDefaultWhat it does
activity.retentionDays90Days to keep account activity entries: sign-ins, credential changes, admin actions. false keeps everything.
security.headerstrueSends HSTS, a CSP and the other security headers. Turn off only when a proxy in front already sets them.
security.cspExtend{}Extra sources per CSP directive, such as { 'img-src': ['https://cdn.example.com'] } (Security headers).
legal.termsVersionfalseThe current terms version. Changing it asks every signed-in user to accept again. false records no acceptance.

payments

KeyDefaultWhat it does
payments.enabledtrueOff bundles no provider and lets every paid check pass.
payments.identity'account''guest' also sells one-time plans to buyers without an account.
payments.modes['one-time', 'subscription']The purchase shapes you sell.
payments.providers['stripe']Any of stripe, polar, lemonsqueezy, creem. The first is the default.
payments.couponFieldtrueWhether the hosted checkout shows its own code box.
payments.pastDueGraceDays14Days a subscription keeps access after its first failed renewal. false ends access at once.
payments.gate'none''plan' locks an organization's pages until it holds a plan.
payments.planspro, promax, lifetimeThe catalogue: mode, currency, displayed prices, trial.
payments.adaptersnoneDeclarations for a provider you add in server/payments/adapters/custom/.

Details, provider setup and the ref variables are on Payments.

access

KeyDefaultWhat it does
access.permissionsusers:read, users:manage, blog:author, insights:readEvery permission a route can check. A permission exists only if it is listed here.
access.rolePermissionsuser, staff, superadminThe app roles, keyed by role, and the permissions each grants.
access.defaultRole'user'What an unrecognised users.role value reads as. Make it the least privileged role.
access.firstAccountRole'superadmin'Granted to the first account that verifies its email while nobody holds users:manage. false turns it off.
access.planPermissionsinsights:read on each paid planThe permissions each plan sells. free is an account with no plan.

See Permissions.

orgs

KeyDefaultWhat it does
orgs.enabledtrueOff registers no organization route or page. The tables still ship.
orgs.levels['organization']The ownership levels, root first. One or two. Each id keys its label in the locale files.
orgs.createAtSignuptrueGives each new account an organization. false leaves it without one until it creates one or buys.
orgs.permissionsorg:manage, org:members:manage, org:billing, org:scopes:manageEvery permission an org role can hold.
orgs.rolesowner, admin, memberEach role's permissions and the levels it can be held at.
orgs.defaultRole'member'The role the invitation form preselects.
orgs.ownerRole'owner'The role an organization's creator gets, and the one the last-owner guard counts.
orgs.invitations{ expiryHours: 336, maxPending: 50 }How long an invitation stays valid and how many can be open per organization. false turns invitations off.
orgs.seatLimitsfree 3, pro 10, promax false, lifetime 10Members each plan admits. false is unlimited. The highest limit among the plans held applies.

See Organizations.

auth

KeyDefaultWhat it does
auth.enabledtrueOff registers no auth route.
auth.primary'password''password', 'passwordless' for magic links, or 'none' for OAuth only.
auth.oauth[]Any of github, google, microsoft. Each needs NUXT_OAUTH_<PROVIDER>_CLIENT_ID and _CLIENT_SECRET, and Microsoft also NUXT_OAUTH_MICROSOFT_TENANT.
auth.totptrueAuthenticator-app codes as a second factor.
auth.passkey'login''login' signs in with a passkey alone, 'second-factor' only confirms a session, false turns passkeys off.
auth.emailVerificationtrueA password signup confirms its address before it can buy, invite or add a way to sign in. Ignored otherwise; passwordless always waits for proof.

See Auth.

email, contact and ai

KeyDefaultWhat it does
email.enabledtrueOff, magic links and password resets are logged in development and refused in production.
email.providers['cloudflare']cloudflare (the send_email binding) and resend, in delivery order. A later provider only gets a failed send.
contact.enabledtrueOff drops /contact and both its routes, and every support link renders nothing.
contact.channels['form', 'reveal']form mails you a message, reveal shows your address after a Turnstile check. At least one; form needs email on.
ai.enabledtrueTurns on the model-call layer. Nothing in the kit calls a model.
ai.providers['workersai']In fallback order. workersai is the AI binding in wrangler.jsonc.
ai.gateway'default'The AI Gateway every call goes through. Third-party models are refused without one.
ai.rolesone per modalityEach role's modality and its model per provider. NUXT_AI_<PROVIDER>_MODELS_<ROLE> overrides a model per deploy.

email.adapters and ai.adapters declare a provider you drop into that part's adapters/custom/. See Email, Contact and AI.

i18n and flags

KeyDefaultWhat it does
i18n.defaultLocale'en'Served at the root. Every other locale gets a /<code> prefix.
i18n.localesen, srEach has a code, a BCP 47 language, a name written in that language, and its file in i18n/locales/.
flags.enabledfalseTurns on feature flags.
flags.providers['selfhosted']In order. The first provider that knows a flag answers, and the declared default is the last resort.
flags.driver'kv'Where selfhosted reads its JSON document: kv or r2.
flags.key'flags.json'The KV key or R2 object key of that document.
flags.purpose'avatars'The storage purpose whose bucket the r2 driver reads.
flags.declarationsmaintenance, newPricingEvery flag, with its type and default.

See Languages and Feature flags.

notifications, storage, account, queue

KeyDefaultWhat it does
notifications.enabledtrueThe dashboard bell and its table (Notifications).
notifications.retentionDays30Days to keep a notification after it is read. Unread ones stay. false keeps everything.
storage.purposesavatars, org-avatarsOne entry per kind of upload: R2 binding and bucket, formats, maxBytes, noun, access, cacheControl (Storage).
account.deletionGraceDays30Days a deleted account can still be restored. false deletes at once.
queue.enabledtrueBackground jobs on Cloudflare Queues. Off, callers do the work inline (Background jobs).

monitoring, analytics, marketing, socialProof

KeyDefaultWhat it does
monitoring.enabledfalseForwards server faults to error trackers (Observability).
monitoring.providers['sentry']sentry and betterstack. Each needs NUXT_MONITORING_<PROVIDER>_DSN.
analytics.enabledtrueServer-side analytics (Analytics).
analytics.providers['cloudflare']cloudflare (Workers Analytics Engine), umami, posthog. Every event goes to each one.
analytics.ingestPath'/api/e'The page-view beacon's route. Words that blockers match, such as track or analytic, fail the build.
analytics.pageviews'ssr+beacon''ssr' counts document requests, 'ssr+beacon' adds client navigations, false counts none.
analytics.consent'implied''explicit' sends nothing until an _ac=1 cookie is set. _ac=0 stops sending under either.
marketing.enabledfalseServer-side conversion events. With a provider listed, the consent banner ships.
marketing.providers[]Any of meta, tiktok, x, reddit.
socialProof.enabledtrueRecent-purchase cards on public pages, drawn from real payment_events rows only.
socialProof.identity'first-name+country'What a card names: none, country, first-name or first-name+country. When a buyer's consent or data for a level is missing, the card falls back down that list.
socialProof.windowHours72How far back a purchase counts.
socialProof.minEvents5Below this many purchases in the window, nothing shows.
socialProof.maxPerSession4Cards one visitor sees per browser session.
socialProof.intervalRange[30, 90]Seconds between cards, drawn from this range.
socialProof.routes['/', '/pricing']Path prefixes the cards appear on, without the locale prefix.

Good to know

  • App code imports this file, so it ships in the browser bundle. Never put a secret in it.
  • Ids that end up in a variable name must match /^[a-z][a-z0-9]*$/: plan ids, AI role ids and the file name of a custom adapter. Nitro maps proMax, pro-max and pro_max to the same variable, which is why the config says promax and workersai.
  • A slot exists only for a provider that was listed when you built. Setting NUXT_PAYMENTS_POLAR_SECRET_KEY does nothing until polar is in payments.providers and the app is rebuilt.
  • Values pass through destr, and an empty line such as NUXT_PAYMENTS_STRIPE_REFS_PRO_MONTH= sets an empty string over the default. Comment out lines you don't use.
  • A new plan, app role or org level also needs its copy in every file in i18n/locales/. plan-copy, role-copy and org-copy in test/unit/ fail until it is there.

A link with a lock opens the kit’s private repository, which every licensee can read.