Byg et lille monorepo uden at drukne i tooling
At have frontend og backend i hver sit repo er lidt som at have to tandbørster i samme glas men kun bruge den ene. Det føles organiseret, indtil du skal skifte tandpasta kl. 6 om morgenen.
Forstå hvornår et monorepo giver mening
Et monorepo er bare: ét Git-repo, flere apps og pakker. Ikke magi, bare en mappe-struktur og nogle scripts.
Det giver især mening, når du:
- Har én frontend og én backend der hænger tæt sammen (typisk et portfolio-projekt eller en lille SaaS-idé).
- Gerne vil dele kode, typer eller validering mellem dem.
- Er træt af at opdatere den samme type eller helper to steder.
Jeg ville ikke starte med monorepo, hvis du:
- Kun har en enkel frontend uden backend.
- Bygger små, adskilte eksperimenter, som ikke taler sammen.
- Allerede kæmper med at få
npm installtil at virke.
Som tommelfingerregel: Hvis du har to projekter der deler model-logik eller typer, er et lille monorepo værd at overveje.
Vælg den simple mappe-struktur fra start
Vi går efter en minimal opsætning:
my-project/
package.json
pnpm-workspace.yaml # hvis du bruger pnpm
apps/
web/ # frontend (f.eks. React + Vite)
api/ # backend (f.eks. Node/Express)
packages/
shared/ # delt kode: typer, utils, validering
Idéen er:
apps/er ting der kører (services, webapps).packages/er ting der genbruges (biblioteker, helpers).
Det ligner strukturen du møder i større setups, men uden alle lagene af tooling. God træning til dit CV uden at skulle slås med 15 config-filer.
Forstå workspaces uden at læse hele npm-dokumentationen
Workspaces betyder: flere pakker i samme repo, der kan linke til hinanden som om de var installeret fra npm.
Du kan bruge npm, Yarn eller pnpm. Jeg er fan af pnpm, men vi tager begge varianter.
npm workspaces opsætning
Øverst i projektet, i package.json:
{
"name": "my-project",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
]
}
private: true forhindrer dig i at publicere hele repoet ved et uheld. Godt for nattesøvn.
pnpm workspaces opsætning
Med pnpm bruger du en separat fil i roden:
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
Og i roden kan du stadig have et simpelt package.json:
{
"name": "my-project",
"private": true
}
Når det er sat op, kan du gøre sådan her fra roden:
npm installellerpnpm installfor at installere alt.npm run dev --workspace apps/webfor at køre scripts i en bestemt app.
Det føles lidt som at have flere små projekter, men kun ét node_modules-lager. Din disk bliver glad.
Byg din første shared package
Nu laver vi en lille delt pakke med typer og validering.
packages/
shared/
package.json
src/
index.ts
packages/shared/package.json:
{
"name": "@my-project/shared",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json"
}
}
Og et simpelt TypeScript-setup i packages/shared/src/index.ts:
export type Todo = {
id: string;
title: string;
completed: boolean;
};
export function validateTodo(input: unknown): input is Todo {
if (typeof input !== "object" || input === null) return false;
const todo = input as Record<string, unknown>;
return (
typeof todo.id === "string" &&
typeof todo.title === "string" &&
typeof todo.completed === "boolean"
);
}
Nu har du én kilde til sandhed for din Todo-model. Backend og frontend kan bruge den samme definition.
Brug shared types i backend
Forestil dig en lille Express API i apps/api.
apps/api/
package.json
src/
index.ts
apps/api/package.json:
{
"name": "api",
"version": "1.0.0",
"scripts": {
"dev": "ts-node-dev src/index.ts"
},
"dependencies": {
"express": "^4.18.0",
"@my-project/shared": "1.0.0"
}
}
Bemærk at vi refererer til @my-project/shared som en normal dependency. Workspace-systemet sørger for at linke det lokalt.
Eksempel på brug i apps/api/src/index.ts:
import express from "express";
import { Todo, validateTodo } from "@my-project/shared";
const app = express();
app.use(express.json());
let todos: Todo[] = [];
app.post("/api/todos", (req, res) => {
if (!validateTodo(req.body)) {
return res.status(400).json({ error: "Invalid todo" });
}
todos.push(req.body);
res.status(201).json(req.body);
});
app.get("/api/todos", (_req, res) => {
res.json(todos);
});
app.listen(3000, () => {
console.log("API listening on http://localhost:3000");
});
Her er pointen: Backend stoler ikke blindt på input. Den bruger den samme valideringsfunktion, som frontend også kan bruge.
Typisk fejl: import-stier i monorepo
Mange prøver først:
import { Todo } from "../../packages/shared/src"; // nej tak
Det virker måske lokalt, men du mister hele pointen med workspaces. Brug altid pakkenavnet (@my-project/shared), ikke relative stier på tværs af apps.
Brug shared types i frontend
Lad os sige du har en Vite + React app i apps/web.
apps/web/
package.json
src/
main.tsx
api.ts
apps/web/package.json:
{
"name": "web",
"version": "1.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@my-project/shared": "1.0.0"
}
}
Og i apps/web/src/api.ts:
import { Todo } from "@my-project/shared";
const API_BASE = "http://localhost:3000";
export async function fetchTodos(): Promise<Todo[]> {
const res = await fetch(`${API_BASE}/api/todos`);
if (!res.ok) throw new Error("Failed to fetch todos");
return res.json();
}
Nu er dine typer synkroniseret automatisk. Hvis du ændrer feltet title til text i shared, får både frontend og backend TypeScript-fejl, indtil du har rettet begge steder.
Gør scripts i roden til din ven
En af fordelene ved monorepo er at du kan styre ting fra roden.
Kør apps samtidig
Installer en lille helper, f.eks. concurrently, i roden:
# i roden
npm install -D concurrently
# eller
pnpm add -D concurrently
Og opdater package.json i roden:
{
"name": "my-project",
"private": true,
"scripts": {
"dev:web": "npm run dev --workspace web",
"dev:api": "npm run dev --workspace api",
"dev": "concurrently "npm run dev:web" "npm run dev:api""
},
"workspaces": ["apps/*", "packages/*"]
}
Nu kan du starte begge apps med én kommando:
npm run dev
# eller
pnpm dev
Det lyder banalt, men det gør en forskel i hverdagen. Mindre “hov, jeg glemte at starte API’en”.
Byg shared før apps
Hvis du bruger TypeScript og outputter til dist/, er det en god idé at bygge shared-pakken før apps.
{
"scripts": {
"build:shared": "npm run build --workspace @my-project/shared",
"build:web": "npm run build --workspace web",
"build:api": "npm run build --workspace api",
"build": "npm run build:shared && npm run build:web && npm run build:api"
}
}
Det er ikke fancy CI, bare en lille kæde af scripts. Men det tvinger dig til at tænke i rækkefølge, lidt ligesom når du opstiller din deploy på f.eks. Vercel eller Netlify.
Tænk dig om med environment variables i monorepo
Env vars bliver tit rodede i monorepos, fordi folk blander frontend- og backend-secrets i den samme fil. Lad være.
Grundregel, samme som jeg skrev om i artiklen om env vars og hemmeligheder:
- Frontend-env er aldrig hemmelige.
- Backend-env kan være hemmelige.
Hold env-filer per app
Gør sådan her:
apps/web/.env.local
apps/api/.env.local
Eksempel til apps/api/.env.local:
PORT=3000
DATABASE_URL=postgres://user:pass@host:5432/mydb
JWT_SECRET=superhemmeligt-ikke-commit-det-her
Eksempel til apps/web/.env.local med Vite:
VITE_API_BASE_URL=http://localhost:3000
Og så en .gitignore i roden, som inkluderer begge:
.env
.env.*
apps/web/.env*
apps/api/.env*
Du kan også have eksempel-filer:
apps/web/.env.example
apps/api/.env.example
Dem kan du godt committe, med tomme eller fake værdier. God stil til portfolio og open source.
Brug env-vars korrekt i frontend
Hvis du bruger Vite, skal env-variabler begynde med VITE_ for at ende i bundlen. Og ja, det er meningen, at alle kan se dem.
Eksempel:
// apps/web/src/api.ts
const API_BASE = import.meta.env.VITE_API_BASE_URL;
Den her værdi må ikke være hemmelig. Hvis du er i tvivl, så antag at alt i frontend er offentligt.
Typisk fejl: dele hemmeligheder i shared
Lad være med at gøre sådan her i packages/shared:
export const JWT_SECRET = process.env.JWT_SECRET!; // nej
Årsagen er simpel: shared bruges både af frontend og backend. Pludselig risikerer du at noget, der kun giver mening på serveren, lander i et client build.
Hold env-tilgang tæt på det sted, hvor du faktisk bruger værdien (typisk i backend-koden).
Få styr på deploy i en monorepo-verden
Deploy er stedet, hvor et pænt lokalt setup ofte vælter. Problemet er sjældent monorepoet i sig selv, men hvordan din host læser det.
Overblik: hvad deployer du?
I den lille monorepo har du typisk:
- En frontend-build (output fra Vite) der kan ligge på Netlify, Vercel eller tilsvarende.
- En backend (Node/Express) der skal køre på en server, f.eks. Railway, Render, Fly.io eller lignende.
De fleste platforme forstår i dag monorepos så længe du fortæller dem:
- Hvad er projektroden for denne service?
- Hvilket build-script skal køres?
- Hvor ligger outputtet?
Typiske fejl under deploy
Her er tre fejl jeg ser igen og igen:
1) Deploy-platformen bruger roden som projektmappe
Sådan noget som:
Build command: npm run build
Publish directory: dist
Men dit frontend-build ligger i apps/web/dist, ikke i roden. Løsning: sæt projektmappen til apps/web i UI’et, eller angiv det i config-filen.
2) Dependencies bliver ikke installeret i apps
Nogle hosts kører kun npm install i det directory, de anser som roden. Hvis du sætter projektroden til apps/web, ved den ikke at du har workspaces i toppen.
Løsning: brug en build-command der starter fra roden, f.eks.:
Build command: npm install && npm run build:web
3) Manglende env vars i produktion
Lokalt har du en .env.local-fil. På din host skal du typisk ind og sætte env vars i deres UI eller via en config-fil. En monorepo ændrer ingenting her, men det er let at glemme, fordi du har flere apps.
Tricket er: tænkt på hver app som sin egen service, også selv om de deler repo.
Hvis du vil nørde mere i deploy, er der en god intro om env og secrets i artiklen om env vars ved deploy, som også gælder for monorepos.
Lav en lille “definition of done” til dit monorepo
Hvis det her skal bruges som portfolio-projekt, kan du bruge den her liste som færdig-mål.
Struktur og opsætning
apps/webogapps/apifindes og kan køre hver for sig.packages/sharedindeholder mindst én type eller funktion, der bruges begge steder.- Workspaces er sat op (npm eller pnpm) og
npm installi roden virker.
Scripts
npm run devi roden starter både frontend og backend.npm run buildbygger i en fornuftig rækkefølge (shared før apps).
Env vars
- Frontend og backend har hver sin env-fil, som er ignored.
- Du har
.env.example-filer for begge apps med placeholders. - Ingen hemmeligheder lækker ind i frontend-koden.
Delte typer og validering
- Mindst én central model (f.eks.
Todo,UserellerPost) bor ishared. - Både frontend og backend bruger den type direkte.
- Der er en simpel valideringsfunktion i
shared, som backend bruger til at tjekke input.
Dokumentation
- Et kort
README.mdi roden der forklarer struktur, scripts og hvordan man starter projektet. - Evt. et lille afsnit i README, hvor du nævner at du har valgt monorepo for at kunne dele typer og reducere rod.
Det lyder måske som meget, men du kan nå hele listen i et beskedent weekendprojekt, især hvis du genbruger simple patterns fra andre artikler på Coding Class.
Næste skridt: gør dit monorepo lidt klogere hver uge
Et monorepo er ikke noget du “indfører” på én dag og så er det perfekt. Det er mere som at organisere skufferne i et køkken. Du starter med to-tre skuffer, og så opdager du senere, at knive og bestik måske ikke skal ligge sammen.
Mit forslag: vælg én lille forbedring ad gangen:
- Uge 1: Få basisstruktur og workspaces til at virke.
- Uge 2: Flyt dine vigtigste typer ind i
shared. - Uge 3: Tilføj et par scripts i roden til
devogbuild. - Uge 4: Ryd op i env-filer og tilføj
.env.example.
Hvis du kun gør én ting anderledes efter at have læst det her, så lav apps/, packages/ og en lille shared-pakke til dine typer. Det giver mest værdi for mindst mulig ekstra hjernekapacitet.








1 kommentar