Monorepo (Hono + React)¶
Workspaces Hono Vite+/React Drizzle Validation
A three-package workspace that wires a Hono backend, a Vite+/React client, and a shared types package together. Pick this template when you want clear separation between server, client, and the contract that connects them — and when you are willing to pay the workspace-tooling cost to get it. If a single package.json is enough, prefer the Full-Stack (single package) template instead.
The shared package exports ReScript types under a Shared.* namespace; the server depends on @<project>/shared and uses those types in route handlers; the client depends on the same workspace and consumes them inside React components. Editing a wire-format record in packages/shared/src/Api.res breaks the build on both sides until they agree — the structural typing is the entire point.
What You Get¶
my-monorepo/
├── package.json # root: workspaces + dev/dev:server/dev:client/test fan-out
├── pnpm-workspace.yaml # only when PM = pnpm; npm/yarn/bun use the workspaces field
├── packages/
│ ├── shared/
│ │ ├── rescript.json # name = @<project>/shared, namespace = "Shared"
│ │ ├── package.json
│ │ └── src/
│ │ ├── Types.res # domain records (Shared.Types.user)
│ │ └── Api.res # wire-format records (Shared.Api.createUserReq)
│ ├── server/
│ │ ├── rescript.json # bs-deps: @rescript/core, @<project>/shared, validation
│ │ ├── package.json # Hono + Drizzle + libsql + validation
│ │ ├── drizzle.config.ts
│ │ ├── vitest.config.mjs # loads vitest.setup.mjs
│ │ ├── vitest.setup.mjs # pins DATABASE_URL=:memory:
│ │ ├── .env.example # DATABASE_URL=file:./data/app.db
│ │ └── src/
│ │ ├── ServerMain.res # entry — calls Server.start()
│ │ ├── Server.res # Hono app: GET/POST /api/users, GET /api/hello
│ │ ├── Hono.res # bindings shared with other Hono templates
│ │ ├── HonoNodeServer.res
│ │ ├── Schema.res # Drizzle SQLite schema (users table)
│ │ ├── Db.res # libsql client + query helpers
│ │ ├── Validation.res # zod or sury — selected in the wizard
│ │ └── __tests__/Server.test.mjs # vitest: app.request("/api/hello") returns 200
│ └── client/
│ ├── rescript.json # bs-deps: ..., @rescript/react, @<project>/shared
│ ├── package.json # Vite+ + React + workspace dep on shared
│ ├── index.html
│ ├── vite.config.mjs # proxies /api/* to the Hono server
│ └── src/
│ ├── Main.res # React root render
│ ├── App.res # users form + list — uses Shared.Types.user
│ ├── ApiClient.res # fetch wrapper, types pinned to Shared.Api.*
│ └── __tests__/ApiClient.test.mjs
├── README.md # workspaces note + Database + Vite+ + Networking sections
├── LICENSE # MIT, holder = project name
├── .nvmrc # Node 24
├── .gitignore # adds dist/, .vite/, packages/*/dist/, packages/*/data/, .env
├── .editorconfig
└── .github/
├── dependabot.yml
└── workflows/ci.yml # install + test fan-out
Wizard Options¶
Option |
Effect |
|---|---|
Project name |
Becomes the npm |
Package manager |
npm / pnpm / yarn / bun. Selects the workspace declaration shape, the per-workspace command syntax, the |
Validation library |
|
The package-manager choice affects three load-bearing things in addition to the install command:
Concern |
pnpm |
yarn |
npm |
bun |
|---|---|---|---|---|
Workspace declaration |
|
|
|
|
Per-workspace command |
|
|
|
|
Workspace dep version |
|
|
|
|
Key Dependencies¶
Root¶
Package |
Purpose |
Version |
|---|---|---|
|
Runs server + client dev watchers in parallel |
|
packages/server¶
Package |
Purpose |
Version |
|---|---|---|
|
ReScript compiler + runtime |
|
|
Workspace dep on the shared types package |
|
|
HTTP framework |
|
|
Node HTTP adapter |
|
|
Validation backend |
|
|
libsql client (SQLite-compatible, Turso-ready) |
|
|
Type-safe SQL builder |
|
|
Migration generator |
|
|
Test runner |
|
|
Coverage provider |
|
|
Pairs |
|
packages/client¶
Package |
Purpose |
Version |
|---|---|---|
|
ReScript compiler + runtime |
|
|
React bindings |
|
|
Workspace dep on the shared types package |
|
|
React runtime |
|
|
Vite+ build tool (vite + vitest + opinionated defaults) |
|
|
Vite+ runtime core |
|
|
Underlying Vite (held as a fallback dep) |
|
|
React fast-refresh plugin |
|
|
Test runner + coverage |
|
|
Pairs |
|
Key Files¶
packages/server/src/Server.res¶
Hono app with three routes (GET /api/hello, GET /api/users, POST /api/users). The POST route validates the body with Validation.parseCreateUserReq, then writes through Drizzle:
app->Hono.post("/api/users", async ctx => {
let raw = await ctx->Hono.req->Hono.jsonBody
switch Validation.parseCreateUserReq(raw) {
| Error(msg) => ctx->Hono.status(400)->Hono.json({"error": msg})
| Ok(payload) =>
let inserted =
await Db.db
->Db.insert(Schema.users)
->Db.values({"name": payload.name, "email": payload.email})
->Db.returning
ctx->Hono.status(201)->Hono.json(inserted->Array.get(0))
}
})
The CORS hook is left commented; the dev workflow does not need it because Vite+ proxies /api/* to the server (same-origin).
packages/server/src/ServerMain.res¶
A single line: Server.start(). The split exists so vitest.setup.mjs can pin DATABASE_URL=:memory: before tests import("../Server.res.mjs") to call app.request("/api/hello") without binding a port.
packages/server/vitest.setup.mjs¶
process.env.DATABASE_URL = ":memory:";
Pinning the URL before the test imports Server.res.mjs (transitively Db.res) ensures the libsql client opens an in-memory database instead of trying to read ./data/app.db, which does not exist in the test temp directory.
packages/client/src/App.res and ApiClient.res¶
App.res is a small users form + list. Its critical detail is the Shared.Api.createUserReq annotation: any drift in packages/shared produces a compile error in both the client and the server, exactly when you want the breakage:
let req: Shared.Api.createUserReq = {name, email}
let _ = await ApiClient.createUser(req)
ApiClient.res wraps fetch and returns Shared.Types.user[] (or Shared.Api.createUserRes), keeping the type round-trip honest end-to-end.
packages/client/vite.config.mjs¶
Vite+ config with a /api/* proxy that forwards to the Hono server. Generated via ProjectFileBuilders.viteConfigWithProxy so it stays consistent with the Vite+/Hono contract used by other templates.
npm Scripts¶
Root¶
Script |
Description |
|---|---|
|
|
|
Per-workspace dispatch to |
|
Per-workspace dispatch to |
|
Per-workspace dispatch to |
|
Fan-out to every workspace exposing a |
|
Same fan-out, but for |
|
|
|
|
|
|
packages/server¶
Script |
Description |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
ReScript compile / watch / clean |
packages/client¶
Script |
Description |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
ReScript compile / watch / clean |
Day-Two Recipes¶
Set Up a Monorepo — IDE-side LSP configuration tips for monorepo workspaces
Add a Hono Endpoint — add a typed POST/GET route on the server side
Set Up Drizzle — extend the Drizzle schema (the template ships one already)
Create a React Component — pattern for adding a new client-side React component
Add OpenAPI Docs — generate OpenAPI docs from the Hono routes
For ReScript-side editor workflows once the project is open, see the Feature Overview.
Notes¶
The workspace declaration shape changes with the package manager. pnpm uses a separate
pnpm-workspace.yaml; npm, yarn, and bun all use theworkspacesfield in the rootpackage.json. The wizard generates the right one automatically.npm rejects
workspace:*as a dependency specifier and treats it as a literal version. The template falls back to*on npm and usesworkspace:*for pnpm, yarn, and bun.concurrentlyruns at three levels. The rootdevruns the two workspace watchers in parallel; each workspacedevrunsrescript -walongside its respective node/vite watcher. Without the innerconcurrently, edits to.resfiles would not recompile whiledevwas running and you would chase stale.res.mjsfor hours — a footgun previous template versions hit.packages/shareddoes not have its ownrescript -w— the rootres:devcovers it because ReScript walks the workspace from the project root. Each workspace also exposesres:build/res:dev/res:cleanfor when you want per-package control.The vitest config pins
DATABASE_URL=:memory:so the smoke test does not try to open the real SQLite file. The committed.env.exampleshows the production-shape default (file:./data/app.db).Vite+ is pre-1.0. If a future release breaks, swap
vite-plusfor plainviteinpackages/client/vite.config.mjsand replace thevpscripts withvite. The classic Vite dep is already declared as a fallback.packages/server/data/,packages/*/dist/,.vite/,.envare gitignored; the SQLite file produced bydb:migratestays out of version control.CORS is off by default because the Vite+ proxy keeps requests same-origin in dev. A ready-to-uncomment
Hono.cors(...)block lives at the top ofpackages/server/src/Server.res. Pin an origin allowlist before deploying separately-hosted client and server.