Skip to main content
Everything here is a rule the platform or the runtime actually enforces — a build that fails, a lint error, a call that throws, or a file that gets overwritten. None of it is style advice.

The runtime is a Cloudflare Worker

Your app is built with OpenNext and deployed as a single Worker. There is no Node.js server process.
No Node.js runtime. Anything that needs a real Node process is unavailable: the filesystem, native modules, child processes, long-lived background threads, and libraries that assume any of those. nodejs_compat covers common Node APIs, not a Node server.
What that rules out:
  • Work finishes inside the request. There is no process that outlives a response. Anything periodic belongs in scheduling-sdk; anything slow belongs behind a job the platform triggers, not a setInterval.
  • Local files are not storage. Writing to disk is not persistence — use a storage-type Data Store.
  • Keep middleware.ts named middleware.ts. Next.js 16 deprecates it in favour of proxy.ts and prints a warning. Do not rename it. Proxy always runs on the Node.js runtime, which the Cloudflare adapter does not support, so the rename produces a build that cannot deploy. The warning is expected.

Bundle size is a hard ceiling

Cloudflare rejects a Worker whose compressed script exceeds 10 MiB. Stardeck warns at 8 MiB, because the failure mode is gradual — every route and every dependency adds to the same bundle.
This is the constraint most likely to bite you from a single npm install. One transitive dependency — a 3.4 MB WebAssembly blob pulled in by an instrumentation package — pushed the base template past the limit and had to be stubbed out in next.config.ts. Before adding a heavy dependency, check whether it is Worker-compatible and what it weighs.
Use npm run build:check in apps/web for a build that does not disturb your dev server.

Rendered output is not cached between requests

The app ships without an incremental cache configured. open-next.config.ts is defineCloudflareConfig({}), and with no cache passed, OpenNext resolves incrementalCache, tagCache and queue to its dummy overrides — every read and write throws "Dummy" cache does not cache anything and is ignored. So treat server rendering as per-request: revalidate, revalidateTag and the Next data cache persist nothing, and background revalidation does not run. If a page is expensive, cache deliberately — Cache-Control on a route response, or a cached value in a Data Store — rather than assuming ISR is doing it for you.

Data access

Data lives in Data Stores: Postgres databases owned by your organization, connected to the app by the platform. Your app reads and writes them through data-store-sdk, which hands you a Kysely instance backed by the Neon HTTP driver.
No interactive transactions. The HTTP driver cannot hold a transaction open across round trips. db.transaction() and db.startTransaction() throw at runtime, and the Stardeck ESLint plugin fails the build before you get there.
Design for this rather than around it: prefer a single statement, make repeated writes idempotent, and reconcile instead of assuming atomicity.

Schema changes are not app code

Creating tables and columns, changing types, and seeding are platform operations, not migrations you commit. They are applied to the same Data Store branch your app reads, and they are versioned by the platform. Do them from the dashboard’s Data Stores editor, or from your editor with Claude Code connected.
Legacy: apps/web/migrations/ and npm run db:migrate. Older apps have a per-app database reached through DATABASE_URL with Kysely migration files. It still runs, and apps already using it keep working — but if your app is not already using it, do not start. New data belongs in a Data Store.

Files the platform owns

Some files in the repository are generated or maintained by the platform. Edits to them are overwritten on the next upgrade, refused by the editing tools, or both. Everything else under apps/web/src is yours.

Changing next.config.ts

The upgrade rail edits this file, so local changes can be reverted by a later platform version. The common case is adding an image host to images.remotePatterns. Ask Stardeck to add it rather than editing the file yourself — the request is quick and the result survives upgrades. A supported per-app override for next.config.ts is something we are tracking; until it exists, an edit here is a change you may have to make twice.

Deploying

apps/web has preview and deploy scripts, but they are the commands the platform runs — they need Stardeck’s Cloudflare credentials, so running npm run deploy from your machine fails. Shipping works like this:
  • Push to main to be seen. The hosted sandbox builds from the platform’s main branch, not from your working tree.
  • Publishing to production is a deliberate action in the dashboard. See Publishing and deployment.
  • Production and sandbox are different data. See Environments.

Authentication and permissions

Auth is project-auth, and the visibility boundary is enforced one hop before your code. The two things that trip people up:
A permission gate cannot be verified in the sandbox. When no real session exists, getSession() returns a mock user with role: "admin" but no permissions at all, so await requireAuth("<key>") redirects to /unauthorized and hasPermission() returns false however the gate is written. A gate that denies in preview proves nothing about whether it is correct. Mint a real persona session with the e2e helpers in @stardeck-customer-apps/project-auth/e2e instead.
  • Declaring a permission key does not grant it. A team member holds an app permission only through what their organization role was granted for that app. Until the grant exists, a correct gate denies everyone.
  • API routes are never gated at the edge. A layout.tsx does not wrap route handlers. Gate /api/* inside each handler.
  • access: "internal" on a Surface is authentication, not authorization. It admits every team member. Anything narrower gates itself by permission.
See User authentication and Members and roles.

Environment variables

Values come from the platform, not from a committed .env. Pull the sandbox set with npm run env:pull (see Run your app locally), and configure them in the dashboard — Environment variables.
  • Client exposure is Next.js’s rule: only NEXT_PUBLIC_-prefixed variables reach the browser. (The dashboard also mirrors non-secret variables with a VITE_ prefix for legacy Vite apps; a Next.js app ignores those.)
  • Secrets are runtime-only. They are not available during the build.
  • Do not construct platform URLs by hand. Use process.env.BASE_URL on the server or process.env.NEXT_PUBLIC_BASE_URL in the browser, plus a path.
  • Never hand-roll a deployment secret. SDK calls sign themselves; if you find yourself building an X-Stardeck-Auth header, you are using the wrong entry point.
A Surface gets its own hostname in production, but app code must never write that hostname.
A cross-surface link is a full navigation, not client-side routing: client state does not survive it. Cross-surface form POSTs do not canonicalize — post to a same-surface route or /api/*.

Modules have one public surface

A Module lives at src/modules/<name>/ and exposes index.ts. Deep imports into another Module’s internals — including type imports and dynamic import() — are a lint error, because install and update swap those files underneath you.
Modules and Blueprints are in Alpha and available to selected organizations.

Nothing inbound reaches your laptop

Webhooks, scheduled jobs, cross-app calls and integration events are delivered by the platform to a deployed URL. A local dev server has none, and this is a deliberate decision rather than a gap: use the sandbox to exercise inbound traffic. Details in Run your app locally.

Operations that do not live in code

These are configured through the platform. Each has a place you do it by hand.
If you need one of these and have repository access but no Stardeck account with the right organization role, you are blocked on a grant, not on code. Ask an organization admin.