Skip to content

Local development

Run Margin on your own machine, with or without accounts, and the commands you will use day to day.

Margin runs locally with one command and no accounts or keys. Accounts switch on as you add configuration.

Requirements#

  • A current Node.js LTS release
  • pnpm
  • Git

The Cloudflare tooling (wrangler and the Workers runtime) is installed as a development dependency, so you do not need a Cloudflare account to run Margin locally.

Set up#

git clone https://github.com/jstEagle/A-Level-Notes-Website.git margin
cd margin
pnpm install
cp .env.example .env.local        # public keys, optional
cp .dev.vars.example .dev.vars    # server secrets, optional
pnpm db:migrate:local             # creates the local D1 database
pnpm dev

Open http://localhost:3000. If port 3000 is taken, the dev server picks the next free port and prints it.

What works without keys#

Everything works with empty configuration files:

WithoutBehaviour
VITE_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEYAccounts are off. The sidebar shows Guest · saved on this device, all data stays in localStorage, and /onboarding saves its answers in the browser.
VectorizeHas no local emulation, so semantic search is off in development and search is lexical only.

To try accounts locally, either create a free Clerk development application and put its two keys in .dev.vars, or turn on local test accounts with MARGIN_DEV_AUTH=1 in .dev.vars, which need no external account. Then run pnpm db:migrate:local so profiles can be stored. See Try accounts locally.

The AI features are switched off by one flag, AI_ENABLED in src/config/ai.ts. To work on them locally, set it to true and put ANTHROPIC_API_KEY and MARGIN_DEV_SITE_AI=1 in .dev.vars. Under pnpm dev every AI request is then answered by Claude, signed in or not. Production builds ignore both. See Configuration.

See Configuration for every variable.

Commands#

CommandWhat it does
pnpm devStart the dev server on port 3000 with hot reload
pnpm checkType-check everything with tsc --noEmit. Run it after every change.
pnpm notes:check [course ...]Compile notes exactly as the build does and report every problem. Run it after every content edit.
pnpm buildProduction build. Fails if server-only code leaks into a client bundle.
pnpm previewServe the production build locally
pnpm db:generateGenerate a migration after editing src/db/schema.ts
pnpm db:migrate:localApply migrations to the local D1 database
pnpm db:migrate:remoteApply migrations to the production D1 database
pnpm dev:reset-authForget every local test account and profile in the local database
pnpm deployBuild and deploy to Cloudflare
pnpm cf-typegenRegenerate worker-configuration.d.ts from wrangler.jsonc

There is no test suite yet: pnpm check, pnpm notes:check and pnpm build are the gates.

Editing content#

Notes and docs reload in the browser as you save them.

  • NOTES_WATCH=0 pnpm dev freezes notes at startup. Use it when working on the interface while content is being rewritten, so the page does not reload on every note edit.
  • Docs under content/docs always reload, whatever NOTES_WATCH is set to.

See Writing notes for the content workflow.

Running several dev servers#

Several dev servers can share one checkout, for example when you run a second copy on another port. Give each its own dependency cache so they do not fight over it:

VITE_CACHE_DIR=node_modules/.vite-docs pnpm dev --port 3103

Changing the database schema#

User data types are defined twice, on purpose: as Zod schemas in src/lib/data/schemas.ts (used by the client and validated on the server) and as Drizzle tables in src/db/schema.ts. Edit both together, then:

pnpm db:generate
pnpm db:migrate:local

Commit the generated migration in drizzle/ with your change.

Conventions#

  • Server-only code (the Anthropic SDK, cloudflare:workers, Clerk's server helpers, request headers) lives in *.server.ts files or is loaded with a dynamic import() inside a createServerFn handler. Never import it from anything a component imports.
  • Data is read and written through the collections from useData(), never through server functions directly. Inserts supply every field, using uid() and Date.now().
  • Messages between panes go through send and subscribe in src/lib/inbox.ts.
  • New tab kinds need an entry in the Tab type in src/lib/workspace.ts, a title and icon for the tab bar, and a case in the workspace's tab content.
  • Design: build from src/components/ui and lucide icons, and use only the semantic colour tokens. No accent colours.
  • Writing: British spelling and sentence-case headings, in the interface and in content.

See Architecture for how the pieces fit together.

Search documentation

Search every page of the Margin docs