Skip to content

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:

  1. Single-stack — one alchemy.run.ts at the workspace root that deploys both packages together. Recommended for most projects.
  2. Multi-stack — each package owns its own alchemy.run.ts and 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.

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.

SituationUse
One team owns both packages, ships them togetherSingle-stack
You want one deploy / destroy command per environmentSingle-stack
Just starting outSingle-stack
Backend and frontend deploy on different cadences (different teams, CI)Multi-stack
Backend has consumers besides the frontend, with their own deploy lifecycleMulti-stack
You want to be able to destroy the frontend without touching the backendMulti-stack

The two example projects:

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.

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.

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.

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):

backend/src/Spec.ts
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.

backend/src/Service.ts
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.

backend/src/Client.ts
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.

backend/src/index.ts
export * from "./Client.ts";
export * from "./Spec.ts";

import { BackendApi, BackendClient } from "backend" now works across the workspace.

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.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:"
}
}
frontend/src/main.tsx
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.

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.

Create alchemy.run.ts at the workspace root:

alchemy.run.ts
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.url is an Output<string>. Passing it into Cloudflare.Vite’s env makes Alchemy build the website after the Worker is deployed, with the resolved URL baked in.
  • Cloudflare.Vite takes a rootDir so a single stack can build a Vite project that lives elsewhere in the workspace — here, the frontend/ directory.
Terminal window
alchemy deploy

One 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.

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 Backend stack handle declared in backend/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.
backend/src/Stack.ts
import * as Alchemy from "alchemy";
export class Backend extends Alchemy.Stack<
Backend,
{
url: string;
}
>()("Backend") {}

Alchemy.Stack<Self, Outputs>()(name) produces a class that:

  1. Names the stack ("Backend") — must match across both stacks.
  2. Declares the output shape — TypeScript enforces it on both sides.
  3. Exposes .make(...) to deploy the stack.
  4. Exposes .stage[name] to reference a deployed stage.

Add Stack.ts to the package barrel so the frontend can import it by package name:

backend/src/index.ts
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.

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 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”
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.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 in dependency order — backend first, then frontend, with matching stage flags:

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

The 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.

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 stageyield* Backend
Always pin to a specific backend stageyield* Backend.stage.prod
Branch on the current stage (e.g. PR previews → staging)a conditional, see Shared database across stages
ConcernSingle-stackMulti-stack
Number of alchemy.run.ts files12
Number of state files per stage12
Deploy orderingImplicit (one plan)Backend first, then frontend
Cross-package reference mechanismDirect Output<string>yield* Backend → state-store lookup
destroy blast radiusWhole appOne package at a time
Best forMost projectsIndependent deploy cadences / consumers