Byg et lille monorepo uden at drukne i tooling

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 install til 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 install eller pnpm install for at installere alt.
  • npm run dev --workspace apps/web for 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 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/web og apps/api findes og kan køre hver for sig.
  • packages/shared indeholder mindst én type eller funktion, der bruges begge steder.
  • Workspaces er sat op (npm eller pnpm) og npm install i roden virker.

Scripts

  • npm run dev i roden starter både frontend og backend.
  • npm run build bygger 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, User eller Post) bor i shared.
  • 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.md i 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 dev og build.
  • 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.

Lasse Falkenberg er typen, der begyndte at rode med HTML og CSS for at lave en simpel bandside – og opdagede, at det var langt sjovere at få knapperne til at virke end at stå på scenen. Siden har han kastet sig over alt fra små JavaScript-snippets til Python-scripts, der kan spare ham for kedeligt, manuelt arbejde i hverdagen.

Han har lært det meste ved at bygge ting, der lige præcis løser hans egne problemer: en lille webapp til at holde styr på brætspilsaftener, et script til at rydde op i rodede mapper, eller en enkel side til at dele noter med venner. Undervejs har han kæmpet sig gennem alle de klassiske fejl – semikolon, forkerte indrykninger og variabler, der hedder noget helt andet end man tror – og det er præcis den rejse, han deler på Coding Class.

På Coding Class skriver Lasse praktiske, jordnære guides, der tager udgangspunkt i små, konkrete opgaver: noget du kan se, teste og bygge videre på med det samme. Han elsker at bryde en opgave ned i små bidder, vise den fulde kode og forklare linje for linje, hvad der sker – inklusive de typiske bugs, du med stor sandsynlighed også støder på.

For Lasse handler kodning ikke om flotte titler eller store ord, men om følelsen af at få noget til at virke – og om at du som læser kan gå derfra med noget, du selv har bygget. Hvis du kan kende glæden ved at få en fejl til endelig at forsvinde, er du lige på bølgelængde med hans måde at lære fra sig på.

Send kommentar

You May Have Missed