diff --git a/examples/_data.ts b/examples/_data.ts index 067f72505..5d96feadf 100644 --- a/examples/_data.ts +++ b/examples/_data.ts @@ -138,6 +138,12 @@ export const items = [ type: "tutorial", category: "Modules and package management", }, + { + title: "Full-stack monorepo with a Node frontend and Deno backend", + href: "/examples/fullstack_monorepo_tutorial/", + type: "tutorial", + category: "Modules and package management", + }, { title: "Run Deno in GitHub Actions", href: "/examples/deno_github_actions_tutorial/", diff --git a/examples/tutorials/fullstack_monorepo.md b/examples/tutorials/fullstack_monorepo.md new file mode 100644 index 000000000..3cec21458 --- /dev/null +++ b/examples/tutorials/fullstack_monorepo.md @@ -0,0 +1,274 @@ +--- +last_modified: 2026-06-18 +title: "Full-stack monorepo with a Node frontend and Deno backend" +description: "Build a monorepo where a Vite + React frontend running on Node shares TypeScript code with a Deno backend through a single workspace package." +url: /examples/fullstack_monorepo_tutorial/ +--- + +A common setup is a frontend built with a Node-based toolchain like Vite next to +a Deno backend, with a package of shared code that both sides import. Deno +workspaces make this work without symlinks, build steps, or copying files: the +frontend, the backend, and the shared package are all members of one workspace, +and each imports the shared package by name. + +In this tutorial we'll build exactly that: + +- `shared/`: a package of TypeScript types and functions, used by both sides. +- `backend/`: a Deno HTTP server that imports `shared`. +- `frontend/`: a Vite + React app that runs on Node and also imports `shared`. + +The end result is a single repository where a change to the shared types is +immediately visible, and type-checked, in both the frontend and the backend. + +## Set up the workspace + +Create the project directory and the three members: + +```sh +mkdir fullstack-monorepo +cd fullstack-monorepo +mkdir shared backend frontend +``` + +The root `deno.json` lists the members and turns on a local `node_modules` +directory, which the Vite frontend needs: + +```json title="deno.json" +{ + "workspace": ["./shared", "./backend", "./frontend"], + "nodeModulesDir": "auto" +} +``` + +`nodeModulesDir: "auto"` is a root-only option: Deno installs npm dependencies +into a `node_modules` directory at the root, which is how Node tooling like Vite +expects to find its packages. + +## Create the shared package + +The shared package holds the code both sides depend on. Here it's a `Dinosaur` +type and a function that describes one. Give it a `name` and an `exports` entry +so other members can import it: + +```json title="shared/deno.json" +{ + "name": "@acme/shared", + "version": "0.1.0", + "exports": "./mod.ts" +} +``` + +```ts title="shared/mod.ts" +export interface Dinosaur { + name: string; + diet: "herbivore" | "carnivore" | "omnivore"; +} + +export function describe(dino: Dinosaur): string { + return `${dino.name} is a ${dino.diet}.`; +} +``` + +A single `deno.json` is all this package needs. Both a Deno member and a Node +member can import it, so there's no separate build or publish step. + +## Create the Deno backend + +The backend is a Deno member that imports `@acme/shared` by name and serves the +data over HTTP. Notice that it imports both the `Dinosaur` type and the +`describe` function from the shared package, with no relative path: + +```json title="backend/deno.json" +{ + "name": "@acme/backend", + "version": "0.1.0", + "exports": "./main.ts", + "tasks": { + "dev": "deno run --allow-net main.ts" + } +} +``` + +```ts title="backend/main.ts" +import { describe, type Dinosaur } from "@acme/shared"; + +const dinosaurs: Dinosaur[] = [ + { name: "Tyrannosaurus", diet: "carnivore" }, + { name: "Triceratops", diet: "herbivore" }, +]; + +Deno.serve((req) => { + const { pathname } = new URL(req.url); + if (pathname === "/api/dinosaurs") { + return Response.json( + dinosaurs.map((d) => ({ ...d, summary: describe(d) })), + ); + } + return new Response("Not found", { status: 404 }); +}); +``` + +You can run it now with `deno task dev` from the `backend` directory, then visit +[http://localhost:8000/api/dinosaurs](http://localhost:8000/api/dinosaurs) to +see the JSON response. + +## Create the Node frontend + +The frontend is a Vite + React app. It's an npm-style member, so it uses a +`package.json` that declares its npm dependencies and lists the shared package +as a `workspace:*` dependency: + +```json title="frontend/package.json" +{ + "name": "@acme/frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "@acme/shared": "workspace:*", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@deno/vite-plugin": "^2.0.2", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "vite": "^6.0.0" + } +} +``` + +Add a `deno.json` alongside it for the JSX and DOM compiler options, so that +`deno check` and your editor type-check the React code correctly: + +```json title="frontend/deno.json" +{ + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react", + "lib": ["ES2020", "DOM", "DOM.Iterable"] + } +} +``` + +The Vite config uses the +[`@deno/vite-plugin`](https://github.com/denoland/deno-vite-plugin), which +teaches Vite to resolve modules the way Deno does. That's what lets the frontend +import `@acme/shared`, a workspace member, by name. It also proxies `/api` +requests to the backend during development: + +```ts title="frontend/vite.config.ts" +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import deno from "@deno/vite-plugin"; + +export default defineConfig({ + plugins: [react(), deno()], + server: { + proxy: { + "/api": "http://localhost:8000", + }, + }, +}); +``` + +Add the HTML entry point and the React app. The app imports the same `Dinosaur` +type and `describe` function from `@acme/shared` that the backend uses: + +```html title="frontend/index.html" + + +
+