Skip to content
rsdlt.dev
Go back

From Path Aliases to a Real Package: Lessons from a pnpm + Turborepo Monorepo

Foambyte started in early 2025 as a single Vite + Capacitor app. In April, when the API arrived, I moved everything into a pnpm + Turborepo monorepo: apps/frontend, apps/server, and room for shared packages. Six months in, the monorepo is one of the best decisions in the project, but the shared code part took two attempts to get right.

This post covers why a monorepo fits a product like this, the shortcut that broke, and the setup that works.

Why a monorepo for a small team

pnpm handles the workspace; Turborepo runs tasks across it in dependency order and caches the results.

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"

Attempt 1: a shared folder behind path aliases

When the frontend and the server first needed the same enums and constants, I created packages/shared and pointed both apps at its source with path aliases:

// vite.config.ts
resolve: { alias: { "@shared": path.resolve(__dirname, "../../packages/shared/src") } }
// tsconfig.json
"paths": { "@shared/*": ["../../../packages/shared/src/*"] }

It worked locally in minutes. Then the Docker builds broke. Each app’s image was built with that app as its context, and a relative path that climbs out of the build context doesn’t exist inside it. I tried build args, extra build contexts, and finally a symlink inside the Dockerfile to recreate the directory layout the alias expected. That last one worked, and it was a clear sign the structure was wrong.

Warning

Path aliases into another package’s src are a hidden dependency. Your package manager doesn’t know about it, your build tool doesn’t know about it, and neither does Docker. It works until the first environment that doesn’t share your folder layout.

Attempt 2: a real internal package

The fix was to make the shared code a proper workspace package, @repo/config-foambyte, with its own package.json, a build step (rslib) that emits dist/, and an explicit dependency from each app:

// apps/frontend/package.json
"dependencies": { "@repo/config-foambyte": "workspace:*" }
// simplified
import { RecipePatchSchema } from "@repo/config-foambyte";

Now pnpm knows the dependency exists, Docker builds just copy the workspace and install it, and imports look like any other package.

The shared package now holds the things both sides must agree on: Zod schemas, option enums, system constants, and the recipe patch merge logic.

Make the build graph explicit

Once apps import the package’s built output, dist/ has to exist before anything else runs. Turborepo expresses that with dependsOn: ["^build"], meaning “build my dependencies first”, and it applies to dev and test, not just build:

"tasks": {
  "build": { "dependsOn": ["^build"] },
  "check-types": { "dependsOn": ["^check-types"] },
  "test": { "dependsOn": ["^build"] },
  "dev": { "persistent": true, "cache": false, "dependsOn": ["^build"] }
}

Without the dev dependency, a fresh clone failed on pnpm dev because no dist/ existed yet. A prepare script in the shared package adds a second guarantee by building it on install.

One pnpm gotcha

pnpm’s strict node_modules layout is a feature: packages can only import what they declare. Some libraries, though, expect their sub-packages to be hoisted. Our UI library worked locally and broke in the Docker build until its packages were hoisted explicitly in .npmrc:

public-hoist-pattern[]=*@heroui/*

Hoist narrowly, by pattern, instead of turning on shamefully-hoist for everything.

Takeaways


I’m Rodrigo Santiago, a product leader who builds. I co-founded Foambyte with my brother Diego, a brewmaster who leads the product as CPO, and I designed and built the platform: architecture, frontend, backend, mobile and infrastructure. If you need someone who can own both the product and the code, let’s talk on LinkedIn.


Share this post:

Previous Post
Self-Hosting a Turborepo App with Docker, Bun, nginx and Traefik
Next Post
Offline-First Editing with TanStack Query Persistence and Last-Write-Wins Patches