Create the workspace and packages

Use one repository for related packages, but give each publishable unit its own manifest and boundary.

Start with the smallest structure that matches ownership

A single library needs one package.json. This quiz system has an SDK, shared runtime contracts, and a private API used by integration tests. A pnpm workspace lets those packages develop together without pretending they are one publishable unit.

quiz-sdk/ ├── package.json ├── pnpm-workspace.yaml ├── tsconfig.json ├── packages/ │ ├── contracts/ │ │ ├── package.json │ │ └── src/index.ts │ ├── sdk/ │ │ ├── package.json │ │ └── src/index.ts │ └── api/ │ ├── package.json │ └── src/index.ts └── pnpm-lock.yaml

Create the repository

mkdir quiz-sdk
cd quiz-sdk
pnpm init
pnpm add -D typescript vitest

pnpm init creates the root manifest. TypeScript and Vitest belong in root devDependencies because maintainers need them to build and test. SDK consumers do not.

Inspect generated files

Initialization tools choose defaults. They do not know your supported runtimes or release policy. Read every generated field. If a tool rejects a package-manager range, use an exact version such as 11.20.0.

Protect the workspace root

{
  "name": "quiz-sdk-workspace",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "vitest run",
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": {
    "typescript": "^7.0.0",
    "vitest": "^4.0.0"
  }
}

private: true prevents accidental publication of the repository root. The root coordinates work. It is not a package for consumers.

Tell pnpm where packages live

Create pnpm-workspace.yaml:

packages:
  - "packages/*"

This pattern makes every direct child of packages a workspace project. pnpm can then link local dependencies and run commands across the repository.

Give every package an identity

Start public packages with private: true as a temporary safety lock. Remove that field only after packing and consumer tests pass.

{
  "name": "@your-scope/quiz-sdk",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "exports": "./src/index.ts"
}

Use a real npm username or organization in place of your-scope. A scope prevents global name collisions and groups related packages.

Keep the API private permanently:

{
  "name": "@your-scope/quiz-api",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "exports": "./src/index.ts"
}

Link packages with workspace:

{
  "dependencies": {
    "@your-scope/quiz-contracts": "workspace:*"
  }
}

During development, workspace:* requires pnpm to use the local contracts package. During packing, pnpm replaces it with the contracts package version, such as 0.1.0. Consumers never see the workspace protocol.

Why each workspace package needs a version

pnpm cannot replace workspace:* if the target package has no version. Even a private API used as a development dependency needs a version when it appears in a package manifest that pnpm packs.

Create the lockfile and links

pnpm install
pnpm typecheck
pnpm test

pnpm install resolves dependencies, writes pnpm-lock.yaml, and creates workspace links. Commit the lockfile so local development and CI resolve the same dependency graph.

Official references

Check your understanding

  1. Why is the root package private?

    Show answer

    The root coordinates packages and development tools. Consumers should never install it, so private: true blocks accidental publication.

  2. What does workspace:* become when pnpm packs the SDK?

    Show answer

    It becomes the linked package's concrete version, such as 0.1.0. That is a normal registry dependency that any package manager can install.