Prisma ORM 8 is here.Read the docs

Getting started

Build a two-service Prisma App from an empty directory and run it on your machine with one command.

By the end of this page you will have a working Prisma App: a quotes API and a public gateway that calls it. A typed contract wires the two together, and one command runs them on your machine. Along the way you will meet every core Composer idea once: a contract, a service, a root module, a build, and a local run.

The app is deliberately tiny, with no database, so you can see the whole shape at once. Adding a Postgres is the natural next step. See Databases when you are ready.

Prerequisites

  • Node.js 22.18 or newer. Check with node --version. Composer hands your TypeScript entry file straight to Node. Node runs .ts files directly only from 22.18.0, the release that turns type stripping on by default. On any older version, including 22.17, Composer commands stop at ERR_UNKNOWN_FILE_EXTENSION naming your own entry file.
  • Bun. Prisma Compute runs Bun, so the server code targets it (Bun.serve). This guide also uses bun build to produce the built output.
  • npm, pnpm, or Bun. Bun workspaces need two extra direct dependencies; see the note in step 1.

You do not need a Prisma account for this page. Local runs never talk to the platform. You only need credentials when you deploy.

1. Set up the project

Create a directory and install the two Composer packages, plus arktype for the contract schemas:

bun init
npm pkg set type module
# couldn't auto-convert command
bun add @prisma/composer @prisma/composer-prisma-cloud arktype
bun add --dev typescript @types/bun

"type": "module" matters: Composer loads your entry file as an ES module, and without it every Composer command stops at COMPOSE.ENTRY_UNLOADABLE ("Cannot use import statement outside a module").

You do not need to pin effect yourself. @prisma/composer and @prisma/composer-prisma-cloud pin every effect-family package their deploy engine needs, so a fresh install resolves a single copy. DEPS.EFFECT_VERSION_CONFLICT only appears when something in your dependency tree, your own effect pin or another dependency's, hoists a different effect where the deploy engine resolves it; see When a deploy stops on an effect version conflict.

Add a tsconfig.json so the compiler checks your wiring the same way the deploy will:

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 are about to create:

my-app/
├── module.ts                  # the root module: the app itself
├── prisma-composer.config.ts  # deploy config (read only by the CLI)
└── 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. Create the quotes service

A service is 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 (arktype, zod, valibot); these examples use arktype:

src/quotes/contract.ts
import { contract, rpc } from '@prisma/composer/service-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 with no behavior. It says what the service is called, what it depends on (nothing yet), how it is 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, the code does not compile:

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

const port = service.port(); // the reserved port, resolved (default 3000)

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 is missing: no URL of anything and no process.env. Every service gets a port for free, read through service.port(). Dependencies arrive through service.load(). Any configuration of your own arrives through service.input() (see Service input). Those three typed accessors are the whole framework contract with your code.

3. Create the gateway service

The gateway depends on the quotes contract. Note the asymmetry with step 2: the quotes service exposed the bare contract (what it provides), while the gateway wraps it in rpc() (what it needs: a client of this contract). Declaring deps: { quotes: rpc(quotesContract) } means service.load() hands the server a ready-made typed client:

src/gateway/service.ts
import node from '@prisma/composer/node';
import { rpc } from '@prisma/composer/service-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.port();

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 is no serve() here. It is an ordinary HTTP server that happens to receive a typed client, and calling quotes.random({}) is an ordinary async function call.

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. provision() returns a ref carrying one port per exposed contract, so quotes.rpc exists because the service declared expose: { rpc: ... }:

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 the Composer CLI reads 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. Build

You own the build. The framework only assembles what you built, and it has one requirement: each entry must be a single self-contained file, with everything inlined except the runtime's own built-ins (bun, bun:*, node:*). Any bundler that produces such a file works. This guide uses Bun. Build the two services separately, because a multi-entry build would split the shared contract code into a chunk neither output contains:

package.json
{
  "scripts": {
    "build": "bun build src/quotes/server.ts --target=bun --outfile dist/quotes/server.mjs && bun build src/gateway/server.ts --target=bun --outfile dist/gateway/server.mjs"
  }
}
bun run build

This is also a good moment to typecheck. The compiler checks the whole wiring, so a dependency wired to the wrong producer or a missing RPC handler fails here, in seconds:

bunx tsc --noEmit

6. Run it locally

One command brings the whole app up on your machine, both services wired together, with no cloud credentials:

bunx prisma@latest dev module.ts

It runs the same pipeline a deploy runs, against local stand-ins for Prisma Compute and Prisma Postgres, and prints each service's local URL:

dev: ready
gateway: http://localhost:3001
quotes: http://localhost:3000

Verify it works by calling the gateway:

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

That response traveled over one typed RPC hop: the gateway received your request, called quotes.random({}) through its injected client, and returned the result.

dev keeps running, watches your built output, and restarts a service when its build changes (run npm run build in another terminal and the affected service restarts). Ctrl-C stops it. Your local data stays, so the next start is warm. See Local development for logs, --fresh, and what persists.

Now try the quotes service directly:

curl -X POST localhost:3000/rpc/random -H 'content-type: application/json' -d '{}'

You get 401 Unauthorized. That is deliberate: Composer mints a service key for each dependency edge and the quotes service accepts only calls from the gateway. Neither service's code mentions a key. See Services and contracts for how this works.

7. Deploy it (optional)

Local dev needed no account. Deploying the same app to Prisma Compute needs a signed-in session:

bunx prisma@latest auth login
bunx prisma@latest deploy module.ts

(In CI, where the browser sign-in is not an option, set PRISMA_SERVICE_TOKEN and PRISMA_WORKSPACE_ID from the Prisma Console instead; see Deploying.)

A new project needs a region, so give it one before the first deploy, in the deploy config or in the environment. The deploy reference shows both ways.

The CLI creates a project named my-app in your workspace (a project this module deployed before is reused; one whose hosted state the CLI cannot verify stops the deploy with HostedStateBootstrapError, so pass --name <unique-name> in that case), provisions both services on Prisma Compute, points the gateway's quotes dependency at the deployed quotes service, and starts everything. The deploy finishes by printing what it made: your module names, the platform resource each became, and the public URLs. Open the gateway's URL and you get a quote.

Re-deploying is idempotent. For an isolated copy of the whole app, deploy a stage:

bunx prisma@latest deploy module.ts --stage demo

Tearing a stage down is the destroy operation of the control API. That operation does not read your auth login session: it needs PRISMA_SERVICE_TOKEN and PRISMA_WORKSPACE_ID in the environment, the same service-token credentials CI uses. Without them, delete the stage's branch in the Console instead. See Deploying for stages, CI, and teardown in full.

Where to go next

  • Apps and Modules: package a service and its database as a reusable Module.
  • Databases: add a Postgres, plain or typed by a Prisma ORM contract.
  • Building blocks: compose the ready-made cron, storage, and streams Modules.
  • Testing: unit tests with mockService, integration tests with bootstrapService.
  • Porting an existing app: keep your server code and declare the app around it.

On this page