Monorepos
A real app usually ships a backend API and a frontend that calls it. In a monorepo, you have two ways to organize the deploy:
- Single-stack — one
alchemy.run.tsat the workspace root that deploys both packages together. Recommended for most projects. - Multi-stack — each package owns its own
alchemy.run.tsand the frontend reads the backend’s deployed outputs through a typed cross-stack reference.
Both shapes share the same package layout and the same browser client. The only thing that changes is where the deploy lives and how the frontend discovers the backend’s URL.
Which one should I use?
Section titled “Which one should I use?”Start with single-stack. It’s simpler, faster to iterate on, and avoids the deploy-ordering and reference-resolution gotchas that come with cross-stack references.
| Situation | Use |
|---|---|
| One team owns both packages, ships them together | Single-stack |
You want one deploy / destroy command per environment | Single-stack |
| Just starting out | Single-stack |
| Backend and frontend deploy on different cadences (different teams, CI) | Multi-stack |
| Backend has consumers besides the frontend, with their own deploy lifecycle | Multi-stack |
You want to be able to destroy the frontend without touching the backend | Multi-stack |
The two example projects:
Shared layout
Section titled “Shared layout”Both shapes start from the same workspace + backend package layout. Walk through this section once; the next two sections just add the deploy glue on top.
Workspace root
Section titled “Workspace root”Set up the workspace package.json:
{ "name": "monorepo", "private": true, "type": "module", "workspaces": ["frontend", "backend"]}Workspaces let frontend import from backend by package name
— that’s the channel the runtime client (and, in the multi-stack
shape, the typed stack handle) flow through.
Backend package
Section titled “Backend package”Create backend/package.json:
{ "name": "backend", "private": true, "type": "module", "dependencies": { "alchemy": "workspace:*", "effect": "catalog:" }, "exports": { ".": { "bun": "./src/index.ts", "import": "./lib/index.js", "default": "./lib/index.js" } }}The bun condition resolves import "backend" straight to the
TypeScript source under Bun, which keeps inner-loop iteration fast.
Define the API schema
Section titled “Define the API schema”Put the HttpApi schema in its own file so it’s importable from
both the Worker (which serves it) and the React app (which calls
it through a typed client):
import * as Schema from "effect/Schema";import * as HttpApi from "effect/unstable/httpapi/HttpApi";import * as HttpApiEndpoint from "effect/unstable/httpapi/HttpApiEndpoint";import * as HttpApiGroup from "effect/unstable/httpapi/HttpApiGroup";
export class Greeting extends Schema.Class<Greeting>("Greeting")({ message: Schema.String,}) {}
export const hello = HttpApiEndpoint.get("hello", "/", { success: Greeting,});
export class HelloGroup extends HttpApiGroup.make("Hello").add(hello) {}
export class BackendApi extends HttpApi.make("BackendApi").add(HelloGroup) {}Pure value-level descriptions. Both sides of the wire share the
same BackendApi constant.
Implement the Worker
Section titled “Implement the Worker”import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Etag from "effect/unstable/http/Etag";import * as HttpPlatform from "effect/unstable/http/HttpPlatform";import * as HttpRouter from "effect/unstable/http/HttpRouter";import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";import { BackendApi, Greeting } from "./Spec.ts";
export default class Service extends Cloudflare.Worker<Service>()( "Service", { main: import.meta.filename }, Effect.gen(function* () { const helloGroup = HttpApiBuilder.group(BackendApi, "Hello", (handlers) => handlers.handle("hello", () => Effect.succeed(new Greeting({ message: "Hello World" })), ), ); return { fetch: HttpApiBuilder.layer(BackendApi).pipe( Layer.provide(helloGroup), Layer.provide([HttpPlatform.layer, Etag.layer]), HttpRouter.toHttpEffect, ), }; }),) {}Standard HttpApi plumbing — see
Effect HTTP API for the long-form
walkthrough.
Build the typed client
Section titled “Build the typed client”import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";import { BackendApi } from "./Spec.ts";
export const BackendClient = (baseUrl: string) => HttpApiClient.make(BackendApi, { baseUrl });A function that takes the deployed URL and returns a fully-typed
HttpApiClient for BackendApi. The React app calls this with
VITE_API_URL.
Re-export the runtime modules
Section titled “Re-export the runtime modules”export * from "./Client.ts";export * from "./Spec.ts";import { BackendApi, BackendClient } from "backend" now works
across the workspace.
Add a ./Client subpath for the browser
Section titled “Add a ./Client subpath for the browser”The browser doesn’t need anything but BackendClient — and
under tree-shaking it’s safer to import it through its own
subpath rather than the package barrel. That keeps the React
build’s dependency graph tightly scoped to the runtime client:
"exports": { ".": { "bun": "./src/index.ts", "import": "./lib/index.js", "default": "./lib/index.js" }, "./Client": { "bun": "./src/Client.ts", "import": "./lib/Client.js", "default": "./lib/Client.js" }}The React app will use import { BackendClient } from "backend/Client".
Frontend package
Section titled “Frontend package”frontend/package.json declares backend as a workspace
dependency:
{ "name": "frontend", "private": true, "type": "module", "dependencies": { "alchemy": "workspace:*", "backend": "workspace:*", "effect": "catalog:", "react": "^19.2.6", "react-dom": "^19.2.6", "vite": "catalog:" }}React entry point
Section titled “React entry point”import { BackendClient } from "backend/Client";import * as Effect from "effect/Effect";import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";import React from "react";import ReactDOM from "react-dom/client";
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8787";
const client = BackendClient(API_URL).pipe( Effect.provide(FetchHttpClient.layer),);
function App() { // … call `client.Hello.hello()` and render the result}
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);The browser only ever imports from "backend/Client" and never
from "backend". The React bundle stays scoped to the runtime
client; nothing else from the backend package leaks into it.
That’s the shared base. From here, choose a deploy shape.
Option 1 — Single-stack (recommended)
Section titled “Option 1 — Single-stack (recommended)”One alchemy.run.ts at the workspace root deploys both packages.
The Worker and the Vite-built website are siblings inside one
stack; the frontend reads the Worker’s URL directly off the
in-memory output, no cross-stack reference required.
Wire it up
Section titled “Wire it up”Create alchemy.run.ts at the workspace root:
import * as Alchemy from "alchemy";import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import { Path } from "effect/Path";import Service from "./backend/src/Service.ts";
export default Alchemy.Stack( "Monorepo", { providers: Cloudflare.providers(), state: Cloudflare.state(), }, Effect.gen(function* () { const backend = yield* Service; const path = yield* Path;
const website = yield* Cloudflare.Vite("Website", { rootDir: path.resolve(import.meta.dirname, "frontend"), env: { VITE_API_URL: backend.url.as<string>(), }, });
return { backendUrl: backend.url.as<string>(), websiteUrl: website.url.as<string>(), }; }),);Two things to call out:
- The Worker’s
backend.urlis anOutput<string>. Passing it intoCloudflare.Vite’senvmakes Alchemy build the website after the Worker is deployed, with the resolved URL baked in. Cloudflare.Vitetakes arootDirso a single stack can build a Vite project that lives elsewhere in the workspace — here, thefrontend/directory.
Deploy
Section titled “Deploy”alchemy deployOne plan, one apply, one set of state. Both resources go up
together; both come down together with alchemy destroy.
That’s it. No subpath-versus-barrel for the stack handle, no
deploy ordering, no Output.stackRef. The single-stack shape is
the one to reach for unless you have a reason not to.
Option 2 — Multi-stack
Section titled “Option 2 — Multi-stack”Each package owns its own alchemy.run.ts. The frontend reads
the backend’s deployed outputs through a cross-stack
reference (yield* Backend), resolved at plan time against the
state store. Each package can deploy and destroy independently.
You pay for that with two extra moving parts:
- A typed
Backendstack handle declared inbackend/src/Stack.ts. - An order-of-deploys constraint: backend must be deployed (to the same stage you’re referencing) before the frontend plan can resolve.
Declare the typed Stack handle
Section titled “Declare the typed Stack handle”import * as Alchemy from "alchemy";
export class Backend extends Alchemy.Stack< Backend, { url: string; }>()("Backend") {}Alchemy.Stack<Self, Outputs>()(name) produces a class that:
- Names the stack (
"Backend") — must match across both stacks. - Declares the output shape — TypeScript enforces it on both sides.
- Exposes
.make(...)to deploy the stack. - Exposes
.stage[name]to reference a deployed stage.
Re-export the stack handle
Section titled “Re-export the stack handle”Add Stack.ts to the package barrel so the frontend can import
it by package name:
export * from "./Client.ts";export * from "./Spec.ts";export * from "./Stack.ts";import { Backend } from "backend" now resolves the typed handle.
The browser never imports from the bare "backend" barrel —
it goes through "backend/Client" — so adding Stack.ts here
does not affect the React bundle.
Deploy the backend with Backend.make
Section titled “Deploy the backend with Backend.make”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 a typed shorthand for Alchemy.Stack that uses
the name and output shape declared on the handle. If the returned
object doesn’t match { url: string }, the file fails to
typecheck.
Deploy the frontend with a cross-stack reference
Section titled “Deploy the frontend with a cross-stack reference”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.Vite("Website", { env: { VITE_API_URL: backend.url, }, });
return { url: website.url.as<string>(), }; }),);yield* Backend resolves to the same stage of the named
stack that the frontend is being deployed to — sam frontend
reads sam backend, pr-42 reads pr-42, and so on. Under the
hood it’s Output.stackRef reading
the backend’s persisted stack output from the state store.
Deploy
Section titled “Deploy”Deploy in dependency order — backend first, then frontend, with matching stage flags:
cd backend && alchemy deploy --stage samcd frontend && alchemy deploy --stage samThe frontend’s plan resolves Backend against the state store
under the current stage (sam). The backend must be deployed to
the same stage first; otherwise evaluation fails with
InvalidReferenceError.
Destroy in reverse — frontend first, then backend.
Pin to a specific stage
Section titled “Pin to a specific stage”The bare yield* Backend is the right default. Sometimes you
want to break stage symmetry — e.g. the production frontend
always points at the production backend even when deployed from
a feature branch. Use Backend.stage.<name> to pin:
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:
const backend = yield* Backend.stage.staging;const backend = yield* Backend.stage["pr-42"];| You want… | Use |
|---|---|
| Frontend’s stage maps 1:1 to the backend’s stage | yield* Backend |
| Always pin to a specific backend stage | yield* Backend.stage.prod |
| Branch on the current stage (e.g. PR previews → staging) | a conditional, see Shared database across stages |
Comparison
Section titled “Comparison”| Concern | Single-stack | Multi-stack |
|---|---|---|
Number of alchemy.run.ts files | 1 | 2 |
| Number of state files per stage | 1 | 2 |
| Deploy ordering | Implicit (one plan) | Backend first, then frontend |
| Cross-package reference mechanism | Direct Output<string> | yield* Backend → state-store lookup |
destroy blast radius | Whole app | One package at a time |
| Best for | Most projects | Independent deploy cadences / consumers |
Related
Section titled “Related”- Stack — defining stacks and stack outputs.
- Inputs and Outputs — the underlying
Output.stackRefandOutput.refoperators. - Shared database across stages — per-resource references between stages of the same stack.
- Stages — naming and isolating per-environment deploys.