Local Development
Purpose and Scope
This page explains how to approach local development in Dub’s monorepo as either a contributor or a self-hoster preparing to run and modify the codebase. Dub is an open-source link attribution platform for short links, conversion tracking, and affiliate programs, and the local workflow is designed around the same product split: the web application lives under apps/web, while reusable capabilities are published or consumed from packages such as @dub/ui, @dub/utils, @dub/cli, email, embeds, and integrations. The practical goal is to install the workspace once, run Turborepo tasks from the root, and understand when to work inside a package directly.
Sources: README.md, package.json, pnpm-workspace.yaml, turbo.json
The official local-development guide describes Dub as a Turborepo monorepo with an apps directory for applications and a packages directory for shared libraries and integrations. The supplied repository files reinforce that shape: the workspace includes apps/*, packages/*, packages/embeds/*, and apps/web/.react-email, while the root package is private and uses pnpm as the package manager. This means most contributors should think in terms of a single workspace graph rather than independent projects. A change to a shared hook in packages/ui, for example, can be consumed by the web app through workspace resolution without publishing a package first.
Sources: pnpm-workspace.yaml, package.json, packages/ui/src/hooks/use-local-storage.ts
Relevant Source Files
apps/web/lib/hooks/use-synced-local-storage.ts— Shows a web-app-specific hook that wraps the shared UI local-storage hook and adds synchronization across browser tabs and same-window listeners.packages/ui/src/hooks/use-local-storage.ts— Provides the shareduseLocalStoragehook consumed by the web app, demonstrating how reusable browser utilities are placed in@dub/ui.packages/utils/src/functions/datetime/get-datetime-local.ts— Provides a small date-time formatting helper from@dub/utils, illustrating shared utilities that normalize browser-facing values.README.md— Documents the project purpose, tech stack, contributor guidance, recommended Node and pnpm versions, common local development issues, and the development seed script.package.json— Defines the root monorepo scripts, package manager version, publishing scripts, and Turborepo-based build, dev, lint, test, clean, and format commands.pnpm-workspace.yaml— Declares the workspace package globs that connectapps, generated React Email output, packages, and embed subpackages.turbo.json— Defines Turborepo task behavior, including persistent uncached development tasks and build outputs.packages/cli/README.md— Documents how to run the Dub CLI locally in development and how to test a production-like linked CLI build.
Workspace Prerequisites and Root Commands
Use the repository root as the starting point for normal development. The README recommends Node v23.11.0 and pnpm 9.15.9, and the root package.json also declares pnpm@9.15.9 as the package manager. Matching those versions matters because build behavior, lockfile resolution, and Turborepo task orchestration are shared across the monorepo. If a local build behaves differently from CI or another contributor’s machine, version drift is one of the first things to eliminate before debugging application code.
Sources: README.md, package.json
The root scripts are intentionally broad. pnpm dev runs turbo dev, pnpm build runs turbo build, pnpm lint runs turbo lint, pnpm test runs turbo run test, and pnpm clean runs turbo clean. Formatting is handled separately with pnpm format, which applies Prettier to TypeScript, TSX, and Markdown files, and pnpm prettier-check validates formatting without writing changes. Turborepo’s configuration marks dev as persistent and uncached, so long-running development servers are treated differently from repeatable build artifacts. Builds depend on upstream package builds and output .next and dist artifacts while excluding the Next.js cache path.
Sources: package.json, turbo.json
A common first setup sequence is:
pnpm install
pnpm devWhen a database schema error appears during local work, the README calls out The table <table-name> does not exist in the current database. as a known issue and recommends running pnpm prisma:push to push the current Prisma schema state. When builds fail unexpectedly, the README recommends confirming Node and pnpm versions, deleting generated dependency and cache directories such as node_modules, .next, and .turbo under apps and packages, reinstalling dependencies with pnpm install, and trying the build again. Those steps are operationally important because this repository relies on generated outputs and workspace package builds.
Sources: README.md, package.json, turbo.json
System-to-Code Mapping
The web application is the primary local development target for product work. The official docs describe apps/web as the application behind app.dub.co and Dub’s redirect infrastructure, built with Next.js and Tailwind CSS. The root README’s tech stack adds the infrastructure context: TypeScript, Prisma, Upstash Redis, Tinybird analytics, PlanetScale, NextAuth.js, BoxyHQ SSO/SAML, Stripe, Resend, Vercel, and Turborepo. For a contributor, this means a local change may cross UI, API, database, analytics, and email boundaries even when the visible feature appears to be a single dashboard interaction.
Sources: README.md, pnpm-workspace.yaml
Shared packages are not just publishing artifacts; they are part of the everyday development loop. packages/ui/src/hooks/use-local-storage.ts exports a generic useLocalStorage<T> hook that safely reads from window.localStorage only in the browser, initializes React state with a parsed stored value or an initial fallback, and writes JSON-serialized values while ignoring blocked-storage or quota failures. That behavior is exactly the kind of reusable browser primitive that belongs in @dub/ui: it can be used by app surfaces without every feature re-implementing server-side rendering guards, parsing, and write-failure tolerance.
Sources: packages/ui/src/hooks/use-local-storage.ts
The web app can layer application-specific behavior on top of shared packages. apps/web/lib/hooks/use-synced-local-storage.ts imports useLocalStorage from @dub/ui, then adds a custom event channel so updates can be synchronized inside the same window as well as across browser windows through the native storage event. The hook accepts a key and initial value, returns the current value plus a setter, supports functional updates, emits JSON-serialized changes, and cleans up listeners in a React effect. This is a useful pattern to follow locally: put generic reusable behavior in packages, and keep app-specific coordination in apps/web.
Sources: apps/web/lib/hooks/use-synced-local-storage.ts, packages/ui/src/hooks/use-local-storage.ts
packages/utils/src/functions/datetime/get-datetime-local.ts illustrates a different shared-package role. getDateTimeLocal accepts an optional Date, falls back to the current date, returns an empty string for invalid dates, adjusts for timezone offset, and formats the value to the minute by trimming an ISO string. Local development often exposes timezone, browser, and form-input edge cases that are hard to see in static code review. Centralizing this conversion in @dub/utils lets contributors use a consistent local datetime representation instead of scattering slightly different implementations through the app.
Sources: packages/utils/src/functions/datetime/get-datetime-local.ts
Running App and Package Workflows
For application work, start with the root development task so Turborepo can coordinate the workspace graph. Then edit the relevant app or package files and let the local development process pick up changes. Because the workspace includes apps/* and packages/*, package changes are part of the same dependency graph as the web app. If a change modifies a shared hook, utility, or component, run the appropriate root checks before opening a pull request so the package and the app are validated together rather than in isolation.
Sources: package.json, pnpm-workspace.yaml, turbo.json
Useful root commands include:
pnpm dev
pnpm build
pnpm lint
pnpm test
pnpm format
pnpm prettier-check
pnpm cleanFor development data, the README documents a seed script that is run from apps/web, not from the root. Basic seeding adds data without deleting existing rows, while the --truncate option deletes existing data first. This distinction matters for local debugging: use the non-truncating form when you want to preserve a scenario you have built manually, and use the truncating form when you need a repeatable reset. The root script command intentionally prints Run this script in apps/web, reinforcing that app-specific scripts should be invoked from the web app directory.
Sources: README.md, package.json
cd apps/web
pnpm run script dev/seed
cd apps/web
pnpm run script dev/seed --truncateThe CLI has a separate local workflow because it is both a workspace package and an executable developer tool. packages/cli/README.md instructs contributors to move into packages/cli, run pnpm dev to build in watch mode, then use a second terminal in the same folder to run pnpm start [command]. For production-like testing, build the package, link it globally with npm link, verify with dub -v, and then run dub [command]. The documented commands include login, config, domains, shorten, links, and help, which makes the CLI useful for testing API-facing behavior from outside the dashboard.
Sources: packages/cli/README.md
Implementation Details to Notice While Developing
Browser storage helpers are written defensively because the web app can render in server and client contexts. useLocalStorage returns null during server-side execution by checking typeof window === "undefined", catches JSON parse failures, and also catches localStorage write failures. Contributors should preserve those guards when changing browser-state code. A hook that directly touches window during render without a guard may work in a browser-only test but fail in a Next.js server-rendered path. Similarly, storage values should remain JSON-compatible because both the UI hook and the synced wrapper serialize and parse values.
Sources: packages/ui/src/hooks/use-local-storage.ts, apps/web/lib/hooks/use-synced-local-storage.ts
The synced wrapper also shows why same-window and cross-window state changes are separate concerns. Browsers fire the native storage event for changes made in other documents, but a component tree may still need notification inside the current document when a setter is called. Dub solves that local concern with an in-memory event channel backed by a Set of listeners. The hook emits after writing through the shared setter and subscribes in an effect that removes both the browser event listener and custom subscription on cleanup. When adding similar local coordination, follow that lifecycle pattern to avoid stale listeners during hot reloads and route transitions.
Sources: apps/web/lib/hooks/use-synced-local-storage.ts
Date-time handling is another source of local-only bugs. getDateTimeLocal adjusts the timestamp by getTimezoneOffset() before deriving the string used for local datetime input-style values. It also returns an empty string for invalid dates rather than propagating Invalid Date text into the UI. When developing features that include scheduling, filtering, reporting windows, or form defaults, prefer the shared helper rather than formatting dates manually. That keeps local behavior consistent across machines in different timezones and makes it easier to reason about tests, screenshots, and bug reports.
Sources: packages/utils/src/functions/datetime/get-datetime-local.ts
Next Steps
After the app runs locally, choose the workflow that matches the change. For dashboard or redirect work, stay centered in apps/web and use root Turborepo commands for checks. For reusable browser behavior, inspect @dub/ui before creating app-local helpers. For formatting, date-time, URL, or constant-style helpers, look in @dub/utils. For command-line workflows, use the CLI package’s watch-mode and linked-package instructions. Before opening a pull request, run the smallest relevant checks locally, confirm the recommended Node and pnpm versions, and include any database seeding or reset steps needed to reproduce your scenario.
Sources: README.md, package.json, packages/cli/README.md