Prisma Composer

Getting started

This guide takes you from an empty directory to a two-service app running on Prisma Cloud. Along the way you'll meet every core idea once: a contract, a service, a root module, a build, a deploy. At the end there's a section on porting an app you already have.

The app is deliberately tiny — a quotes API and a public gateway that calls it, no database — so you can see the whole shape at once. Adding a Postgres (including a Prisma Next-typed one) is the first thing to do after, and Building an app covers it.

You'll need:

1. Project setup

mkdir my-app && cd my-app && pnpm init
pnpm add @prisma/composer @prisma/composer-prisma-cloud arktype
pnpm add -D tsdown typescript @types/bun
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Preserve",
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "noEmit": true,
    "strict": true,
    "skipLibCheck": true,
    "types": ["bun"]
  },
  "include": ["module.ts", "src"]
}

This is what you're about to create:

my-app/
├── module.ts                  # the root module — the app itself
├── prisma-composer.config.ts  # deploy config (read only by the CLI)
├── tsdown.config.ts           # your build
└── src/
    ├── quotes/
    │   ├── contract.ts        # the quotes service's public API, as types
    │   ├── service.ts         # what quotes is: deps + build + what it exposes
    │   └── server.ts          # the code that actually runs
    └── gateway/
        ├── service.ts
        └── server.ts

2. The quotes service

Three files. First, the contract — the API other services will call, written as schemas. It lives with the service that owns it. Any Standard Schema validator works; the examples use arktype:

// src/quotes/contract.ts
import { contract, rpc } from '@prisma/composer/rpc';
import { type } from 'arktype';

export const quotesContract = contract({
  random: rpc({ input: type({}), output: type({ quote: 'string' }) }),
});

Second, the service declaration. This is pure data — no behavior. It says what the service is called, what it depends on (nothing yet), how it's built, and which contract it exposes:

// src/quotes/service.ts
import node from '@prisma/composer/node';
import { compute } from '@prisma/composer-prisma-cloud';
import { quotesContract } from './contract.ts';

export default compute({
  name: 'quotes',
  deps: {},
  build: node({ module: import.meta.url, entry: '../../dist/quotes/server.mjs' }),
  expose: { rpc: quotesContract },
});

Third, the server — the code your build turns into dist/quotes/server.mjs and the platform boots. serve() generates the HTTP handler from the contract; if you forget a handler or return the wrong shape, it doesn't compile:

// src/quotes/server.ts
import { serve } from '@prisma/composer/rpc';
import service from './service.ts';

const { port } = service.config();

const QUOTES = [
  'Simplicity is prerequisite for reliability.',
  'Make it work, make it right, make it fast.',
];

const handler = serve(service, {
  rpc: {
    random: async () => ({ quote: QUOTES[Math.floor(Math.random() * QUOTES.length)]! }),
  },
});
export default handler;

// Bind all interfaces — Compute routes external HTTP to the VM, so a
// loopback-only listener would be unreachable.
Bun.serve({ port, hostname: '0.0.0.0', fetch: handler });

Notice what's missing: no port constant, no URL of anything, no process.env. Configuration arrives through service.config(), dependencies through service.load() — that's the whole framework contract with your code.

3. The gateway service

The gateway depends on the quotes contract. Declaring deps: { quotes: rpc(quotesContract) } means service.load() hands the server a ready-made, typed client — calling it is just an async function call:

// src/gateway/service.ts
import node from '@prisma/composer/node';
import { rpc } from '@prisma/composer/rpc';
import { compute } from '@prisma/composer-prisma-cloud';
import { quotesContract } from '../quotes/contract.ts';

export default compute({
  name: 'gateway',
  deps: { quotes: rpc(quotesContract) },
  build: node({ module: import.meta.url, entry: '../../dist/gateway/server.mjs' }),
});
// src/gateway/server.ts
import service from './service.ts';

const { quotes } = service.load();
const { port } = service.config();

Bun.serve({
  port,
  hostname: '0.0.0.0',
  fetch: async () => {
    const { quote } = await quotes.random({});
    return new Response(quote);
  },
});

The gateway exposes no contract of its own, so there's no serve() here — it's an ordinary HTTP server that happens to receive a typed client.

4. Compose the app

The root module is the app. It provisions both services and wires the quotes service's exposed port into the gateway's dependency slot:

// module.ts
import { module } from '@prisma/composer';
import gatewayService from './src/gateway/service.ts';
import quotesService from './src/quotes/service.ts';

export default module('my-app', ({ provision }) => {
  const quotes = provision(quotesService);
  provision(gatewayService, { deps: { quotes: quotes.rpc } });
});

Next to it goes the deploy config. Only prisma-composer deploy/destroy read this file — your app code never imports it:

// prisma-composer.config.ts
import { defineConfig } from '@prisma/composer/config';
import { nodeBuild } from '@prisma/composer/node/control';
import { prismaCloud, prismaState } from '@prisma/composer-prisma-cloud/control';

export default defineConfig({
  extensions: [prismaCloud(), nodeBuild()],
  state: () => prismaState(),
});

5. Run it locally

There's no dev server yet (it's on the roadmap) — but a server entry is just a program, and locally service.load() and service.config() read plain environment variables named after the slot: COMPOSER_PORT for the service's own port param, COMPOSER_QUOTES_URL for the quotes dependency's URL. A missing required value fails loudly at boot with the exact variable name it wanted.

Two terminals:

# terminal 1 — quotes; port defaults to 3000
bun run src/quotes/server.ts

# terminal 2 — gateway on 3001, pointed at the local quotes
COMPOSER_PORT=3001 COMPOSER_QUOTES_URL=http://localhost:3000/ bun run src/gateway/server.ts

curl localhost:3001
# Make it work, make it right, make it fast.

In production nobody writes these variables by hand — the deploy provisions them from the wiring in module.ts. Locally you are the deploy.

6. Build and deploy

You own the build — the framework only assembles what you built. The shipped tsdown preset makes each entry a single self-contained file. Two services means two builds into two output directories (not one multi-entry build, which would split the shared contract code into a chunk neither output contains):

// tsdown.config.ts
import { prismaTsDownConfig } from '@prisma/composer/tsdown';

export default [
  prismaTsDownConfig({ entry: { server: 'src/quotes/server.ts' }, outDir: 'dist/quotes' }),
  prismaTsDownConfig({ entry: { server: 'src/gateway/server.ts' }, outDir: 'dist/gateway' }),
];
pnpm exec tsdown

Deploying needs exactly two environment variables. Create a service token in your workspace in the Prisma Console; the workspace id is in the workspace's settings:

export PRISMA_SERVICE_TOKEN=...
export PRISMA_WORKSPACE_ID=...

pnpm exec prisma-composer deploy module.ts

The CLI creates a Project named my-app in your workspace, provisions both services on Prisma Compute, points the gateway's quotes dependency at the deployed quotes service, and starts everything. Each service's public URL is its Compute service endpoint, shown in the Console. Open the gateway's URL: you get a quote, served over one typed RPC hop.

Re-deploying is idempotent — it updates the same Project. For an isolated copy of the whole app (own services, own config), deploy a stage, and tear it down when you're done:

pnpm exec prisma-composer deploy module.ts --stage demo
pnpm exec prisma-composer destroy module.ts --stage demo

Porting an existing app

You don't rewrite an app to put it on Composer — you declare it. The server code you already have stays the server; you add the declaration around it.

A Node/Bun service. Add a service.ts with compute({ name, deps, build, entry }) pointing at your built entry, then make three changes to the server itself:

  1. Read the port from service.config() instead of process.env.PORT, and bind 0.0.0.0.
  2. Replace every process.env read with a declared param (values you configure), a secret (credentials — see Building an app § Secrets), or a dependency.
  3. If it talks to Postgres: declare deps: { db: postgres() } and build your existing client (pg, Bun's SQL, whatever you use today) from the injected db.url instead of a connection-string env var.

Your build must produce a self-contained entry file — prismaTsDownConfig does that; keep your own build if it already does.

A Next.js app. Use the nextjs build adapter instead of node; next build with output: 'standalone' is the whole build:

export default compute({
  name: 'web',
  deps: { api: rpc(apiContract) },
  build: nextjs({ module: import.meta.url, appDir: '..' }),
});

Add nextjsBuild() from @prisma/composer/nextjs/control to the deploy config's extensions. Any page or server action that calls service.load() needs export const dynamic = 'force-dynamic', because the runtime environment doesn't exist at build time. examples/storefront-auth is a complete ported-shaped app: a Next.js frontend calling a Bun API service that owns a Postgres.

More than one service. Port them into one module.ts and replace the URLs they used to reach each other with contracts — that's the payoff: the edges become typed, and every environment (production, stages, tests) gets the wiring for free.

Where to go next