Package Distribution
How QUESTPIE packages are built, versioned, and published — tsdown, exports strategy, changesets.
This guide covers how QUESTPIE packages are built, structured, and published as npm packages. It applies to the core questpie package, @questpie/admin, adapters, and any custom module packages.
Monorepo Structure
questpie-cms/
├── packages/
│ ├── questpie/ # Core framework
│ ├── admin/ # Admin UI
│ ├── openapi/ # OpenAPI integration
│ ├── elysia/ # Elysia adapter
│ ├── hono/ # Hono adapter
│ ├── next/ # Next.js integration
│ ├── tanstack-query/ # TanStack Query client
│ └── create-questpie/ # Project scaffolder
├── apps/
│ └── docs/ # Documentation site
├── examples/
│ ├── city-portal/
│ └── tanstack-barbershop/
├── turbo.json # Turborepo task config
├── .oxlintrc.json # Linting configuration
├── .oxfmtrc.json # Formatting configuration
└── package.json # Workspace rootTooling:
- Runtime: Bun
- Package manager: pnpm workspaces (via
workspacesfield in rootpackage.json) - Task orchestration: Turborepo
- Build: tsdown (TypeScript -> ESM)
- Versioning: Changesets
- Formatting: Oxfmt
- Linting: Oxlint
Package Structure
Every published package follows this structure:
packages/my-package/
├── src/
│ └── exports/ # Public API entry points
│ ├── index.ts # Main entry (.)
│ ├── client.ts # Client entry (./client)
│ └── server.ts # Server entry (./server)
├── dist/ # Build output (gitignored)
│ ├── index.mjs
│ ├── index.d.mts
│ ├── client.mjs
│ ├── client.d.mts
│ └── ...
├── tsconfig.json
├── tsdown.config.ts
└── package.jsonThe src/exports/ directory centralizes the public API. Internal modules are not directly importable by consumers.
Dual Exports Strategy
Each package uses a dual exports pattern — development imports resolve to TypeScript source, published imports resolve to compiled output:
{
"type": "module",
"main": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./src/exports/index.ts"
},
"./server": {
"types": "./dist/server.d.mts",
"default": "./src/exports/server.ts"
},
"./client": {
"types": "./dist/client.d.mts",
"default": "./src/exports/client.ts"
}
},
"publishConfig": {
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"./server": {
"types": "./dist/server.d.mts",
"default": "./dist/server.mjs"
},
"./client": {
"types": "./dist/client.d.mts",
"default": "./dist/client.mjs"
}
}
},
"files": ["dist"]
}Why dual exports?
- During development (monorepo),
exports.defaultpoints to.tssource — no build step needed, fast HMR - When published,
publishConfig.exportsoverrides — consumers get compiled.mjs+.d.mts typesalways points todist/— TypeScript gets declaration files for both scenarios
Build System (tsdown)
All packages use tsdown for building:
import { defineConfig } from "tsdown";
export default defineConfig({
entry: ["src/exports/*.ts"], // Entry points from exports directory
outDir: "dist",
format: ["esm"], // ESM only, no CommonJS
clean: true, // Clean dist/ before build
dts: { sourcemap: false }, // Generate .d.mts declaration files
unbundle: true, // Don't bundle imports (keep as external)
});Key settings:
- ESM only — all packages use
"type": "module", no CommonJS output - Unbundled — external dependencies stay as imports, not bundled
- Declaration files —
.d.mtsgenerated alongside.mjs
Special Build Features
Admin package — includes React Compiler:
plugins: [
pluginBabel({
plugins: ["babel-plugin-react-compiler"],
}),
],
copy: [{ from: "src/client/styles/**/*.css", to: "dist/client/styles" }],CLI packages — mark output as executable:
onSuccess: async () => {
await chmod("dist/index.mjs", 0o755);
},Path Aliases
Packages use imports for internal path aliases:
{
"imports": {
"#questpie/*": "./src/*"
}
}{
"compilerOptions": {
"paths": {
"#questpie/*": ["./src/*"]
}
}
}This prevents deep imports from external consumers while keeping internal imports clean.
Workspace Dependencies
Packages reference each other via workspace:*:
{
"dependencies": {
"questpie": "workspace:*"
},
"peerDependencies": {
"questpie": "workspace:*"
}
}During publishing, workspace:* is resolved to the actual version: workspace:* -> ^2.0.0.
Versioning (Changesets)
The project uses Changesets for version management:
{
"changelog": [
"@changesets/changelog-github",
{ "repo": "questpie/questpie" }
],
"commit": false,
"fixed": [
[
"questpie",
"@questpie/admin",
"@questpie/elysia",
"@questpie/hono",
"@questpie/next",
"@questpie/tanstack-query"
]
],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["city-portal-example", "tanstack-barbershop-example"]
}Key decisions:
- Lock-step versioning — all core packages (
fixedgroup) are versioned together - Public access — scoped packages published as public
- Examples ignored — example projects excluded from version tracking
- Internal deps — automatically bumped to patch when changed
Publishing
The publish flow is handled by a custom script that bridges npm and Bun incompatibilities:
# Full release flow
bun run release
# Which runs:
# 1. turbo run build --filter='./packages/*'
# 2. bun run scripts/publish.tsThe scripts/publish.ts script:
- Pre-publish: For each package:
- Applies
publishConfigoverrides topackage.json(replacesexportswith compiled paths) - Resolves
workspace:*to actual versions (^2.0.0)
- Applies
- Publish: Runs
bunx changeset publish - Post-publish: Restores original
package.jsonfiles
Turborepo Tasks
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": [".next/**", "!.next/cache/**"]
},
"test": {
"dependsOn": ["^build"],
"cache": false
},
"check-types": {
"dependsOn": ["^check-types"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}builddepends on^build(dependencies built first)testdepends on^build(packages must be built before testing)devis non-persistent and uncached
Codegen in Package Context
Packages that contain modules (like questpie and @questpie/admin) run codegen before the tsdown build:
# 1. Run codegen to generate .generated/module.ts for all modules
bunx questpie generate
# 2. Build with tsdown (includes generated files)
bunx tsdownThe generated .generated/module.ts files are committed to git and included in the published package via dist/. They are not in .gitignore.
See Building a Module for the full package-mode codegen workflow.
TypeScript Configuration
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"incremental": true,
"composite": true,
"paths": {
"#questpie/*": ["./src/*"]
}
},
"include": ["src", "src/server/modules/**/.generated/*.ts"]
}Note: include explicitly adds .generated/ directories so TypeScript processes them.
Related Pages
- Building a Module — Create and publish module packages
- Building a Plugin — Create codegen plugins
- Codegen — Generated output details