Skip to content

Multiple Stacks

Each package owns its own alchemy.run.ts (examples/monorepo-multi-stack):

Terminal window
.
├── package.json # workspaces: ["frontend", "backend"]
├── backend/
├── alchemy.run.ts # Backend Stack — deploys the Worker
├── package.json # name: "backend"
└── src/
├── Service.ts # the Worker
└── Stack.ts # typed handle — outputs { url: string }
└── frontend/
├── alchemy.run.ts # Frontend Stack — references Backend
├── package.json # depends on "backend": "workspace:*"
└── src/
└── main.tsx # reads import.meta.env.VITE_API_URL

There is no shared plan and no live connection between the Stacks. The backend deploys and persists its outputs to the state store; the frontend’s plan reads those persisted outputs back through a typed Stack handle. That one indirection is what buys independent deploys, independent destroys, and independent CI — at the cost of a deploy order.

Three small files make it work.

backend/src/Stack.ts
import * as Alchemy from "alchemy";
export class Backend extends Alchemy.Stack<
Backend,
{
url: string;
}
>()("Backend") {}

The handle is the contract between the two Stacks: it names the Stack ("Backend") and declares its output shape ({ url: string }). Both sides import this class — the backend to deploy against it, the frontend to reference it — so TypeScript enforces the contract in both directions.

backend/alchemy.run.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import Service from "./src/Service.ts";
import { Backend } from "./src/Stack.ts";
export default Backend.make(
{
providers: Cloudflare.providers(),
state: Cloudflare.state(),
},
Effect.gen(function* () {
const api = yield* Service;
return {
url: api.url.as<string>(),
};
}),
);

Backend.make is Alchemy.Stack bound to the handle: if the returned object doesn’t match { url: string }, this file fails to typecheck. On deploy, the resolved url is persisted to the state store as the Stack’s output.

frontend/alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import { Backend } from "backend";
import * as Effect from "effect/Effect";
export default Alchemy.Stack(
"Frontend",
{
providers: Cloudflare.providers(),
state: Cloudflare.state(),
},
Effect.gen(function* () {
const backend = yield* Backend;
const website = yield* Cloudflare.Website.Vite("Website", {
env: {
VITE_API_URL: backend.url,
},
});
return {
url: website.url.as<string>(),
};
}),
);

yield* Backend reads the backend’s persisted outputs from the state store at plan time and returns them fully typed — backend.url is the string the handle declared. It resolves the same stage the frontend is being deployed to: the sam frontend reads the sam backend, pr-42 reads pr-42.

The import works because frontend/package.json depends on "backend": "workspace:*" — the handle travels through the workspace like any other export.

Terminal window
cd backend && alchemy deploy --stage sam
cd frontend && alchemy deploy --stage sam

The backend must be deployed to the stage first — if it isn’t, the frontend’s plan fails with InvalidReferenceError because there are no persisted outputs to read. Destroy in reverse: frontend first, then backend.

Each alchemy.run.ts runs from its own package directory, so relative paths (main, rootDir) resolve against the package — the root-relative path trap of the single-stack layout doesn’t apply here.

The bare yield* Backend (same stage on both sides) is the right default. To break stage symmetry — for example, every frontend preview points at the production backend — pin with Backend.stage.<name>:

Effect.gen(function* () {
const backend = yield* Backend;
const backend = yield* Backend.stage.prod;
// ...
})

Backend.stage is a proxy keyed by stage name — any string works (Backend.stage.staging, Backend.stage["pr-42"]).

  • Monorepo — the high-level chooser.
  • Single Stack — the one-Stack alternative: one plan, one state file, paths anchored to the root.
  • References — when to split Stacks, how a reference resolves (typing, plan-time state reads, InvalidReferenceError), and stage pinning in depth.