Building on Graviton

Constraints

What the Graviton runtime refuses, which files the platform owns, and the operations that are configured outside your repository.

Each rule here is enforced: a build that fails, a lint error, a call that throws, or a file that gets overwritten.

The runtime depends on App size

App sizeRuns asLimits
Standard (default)One Cloudflare Worker, built with OpenNext10 MiB compressed bundle, no Node.js runtime, no rendering cache
LargeA Node.js server build with more memoryNo Worker bundle ceiling

Change it in Settings → General → Hosting & behavior → App size, where your organization has the setting. It applies the next time you publish. Large needs a recent version. If the app can't use it, the picker says why (usually: take an update).

Note:

Write Worker-compatible code on either size. An app can move back to Standard, and the rules below marked Standard are what break first when it does.

Standard: no Node.js runtime

nodejs_compat covers common Node APIs, not a Node server. The filesystem, native modules, child processes, long-lived background threads and libraries that assume them are unavailable.

  • Work finishes inside the request. Periodic work belongs in scheduling-sdk. Slow work belongs behind a job the platform triggers, not a setInterval.
  • Local files are not storage. Use a storage-type Data Store.

Standard: bundle size is a hard ceiling

Cloudflare rejects a Worker whose compressed script exceeds 10 MiB. Stardeck warns at 8 MiB. Every route and dependency adds to the same bundle, so check a heavy dependency's size and Worker compatibility before adding it. Run npm run build:check in apps/web for a build that does not disturb your dev server.

Standard: rendered output is not cached

open-next.config.ts is defineCloudflareConfig({}), so OpenNext uses its dummy cache: nothing is stored. revalidate, revalidateTag and the Next data cache persist nothing. Treat rendering as per-request on either size and cache deliberately (Cache-Control on a route response, or a value in a Data Store).

Both sizes: keep middleware.ts

Next.js 16 deprecates middleware.ts 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 at Standard size. The warning is expected.

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.

Warning:

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.

// Rejected by lint, throws at runtime
await db.transaction().execute(async (trx) => {
  await trx.insertInto("orders").values(order).execute();
  await trx.updateTable("inventory").set({ count }).execute();
});

// Fold the work into one statement
await db
  .with("new_order", (qb) => qb.insertInto("orders").values(order).returning("id"))
  .updateTable("inventory")
  .set({ count })
  .execute();

// Or run the statements in sequence and make the second one idempotent
await db.insertInto("orders").values(order).execute();
await db.updateTable("inventory").set({ count }).execute();

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 your agent connected. A production deploy lists the schema changes still pending and applies them with the code.

Module tables are the exception. Each Module's tables are defined in the repository, in src/modules/<name>/datastore/schema.sql, with one ledger entry per change in src/modules/<name>/migrations/. Stardeck applies those entries to the app's Data Store when the app is created and when you take an update, and the ledger refuses an entry that drops a table or column. The app's AGENTS.md section Applying schema changes is the loop your agent follows.

Warning:

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 are generated or maintained by Stardeck. Edits to them are overwritten, conflict with the next update, or are refused by the editing tools.

PathOwnerWhy
apps/web/next.config.tsStardeckChanged by updates. See changing it below
apps/web/open-next.config.tsplatformDeployment adapter configuration
apps/web/src/app/api/*/[[...path]]/route.tsSDKsPassthrough routes for auth, payments, and storage
AGENTS.mdStardeckChanged by updates; put project notes in a separate file
Note:

Build outputs, not source. stardeck-compose regenerates apps/web/src/*.gen.ts (modules.gen.ts, module-contributions.gen.ts, module-i18n.gen.ts, module-init.server.gen.ts, module-datastores.gen.ts, module-server.gen.ts) and the generated page.tsx / route.ts adapters carrying a @stardeck-module-rail-generated marker from Module source, through the pre* hooks on every npm run dev | build | lint | typecheck | test. They are gitignored and never committed, so an edit to one is overwritten by the next run. Change the Module's source or its module.json instead.

Everything else under apps/web/src is yours.

Changing next.config.ts

Updates edit this file, so a local change can conflict with or be reverted by a later update. The common case is adding an image host to images.remotePatterns.

Ask Stardeck to add it rather than editing the file. There is no supported per-app override yet, so an edit here may need redoing after an update.

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:

flowchart LR
    L["Your commit"] --> M["push a branch"] --> V["Branch preview deploy"]
    M --> R["Pull request into the default branch"] --> P["Production deploy"] --> W["Production"]
  • Pushing doesn't update the hosted sandbox. Load a commit into it from Git history, or deploy the branch as a preview. Each tracked branch has its own sandbox.
  • The default branch changes only through a pull request. GitHub refuses a direct push.
  • Publishing to production is a deliberate action, from Settings → Deployments → Production or your agent's deploy_app, which asks you to confirm first. Production deploys from the default branch. 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:

Warning:

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.

// Bad: breaks in dev sandboxes and branch previews
<Link href="https://acme-admin.example.com/admin/orders/123">

// Good: the platform canonicalizes it to the owning surface host
<Link href="/admin/orders/123">

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.

// Bad
import { list } from "@/modules/booking/server/reservations";

// Good
import { listReservations } from "@/modules/booking";

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.

OperationWhere you do it
Create tables, add columns, change types, seed dataDashboard → Data Stores, or MCP tools; Module tables through their ledger
Declare an app permission keyAgent tooling or MCP tools
Grant a permission to an organization roleDashboard app settings, or MCP tools
Connect a Data Store to the appDashboard → Data Stores → Connecting
Turn a Module on or offSettings → Modules
Take an updateUpdate available in the project toolbar, or locally
Set environment variablesDashboard → Environment variables
Add a custom domainDashboard → Custom domains
Publish to productionSettings → Deployments → Production, see Publishing and deployment
Capture or refresh Guide screenshotsAgent tooling in the app's repository
Note:

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.