Skip to content

Architecture

How Margin is built, from the build-time content pipeline to workspace state, data sync, server functions and AI.

Margin is a single TanStack Start application that runs on Cloudflare Workers. It is designed to feel instant: every note is compiled into the bundle at build time, tabs and panes are pure client state, and personal data is written optimistically and synced in the background.

Stack#

LayerTechnology
FrameworkTanStack Start with React 19 and TypeScript, server-rendered
Routing, dataTanStack Router, TanStack Query and TanStack DB collections
HostingCloudflare Workers, via @cloudflare/vite-plugin
DatabaseCloudflare D1 (SQLite) through Drizzle ORM
Key-valueCloudflare KV, for development-only rate limits and local test accounts
SearchMiniSearch in the browser; Vectorize and Workers AI embeddings for semantic search
AccountsClerk (Google, email codes), optional
AISwitched off (AI_ENABLED in src/config/ai.ts); a provider layer kept for later
Maths and codeKaTeX with mhchem and Shiki, at build time; a custom canvas grapher
UIshadcn/ui on Radix, Tailwind CSS v4, lucide icons

Repository layout#

content/
  subjects.json            Subjects in display order, each listing course slugs
  courses/<course>.json    One course: units -> note ids, in syllabus order
  notes/<course>/**.md     One Markdown note per topic
  docs/**.md               These docs
  README.md                The writing standard for notes
vite/
  notes-plugin.ts          Compiles notes into virtual modules
  docs-plugin.ts           Compiles docs with the same pipeline
  code-highlight.ts        Shiki theme and the Cambridge pseudocode grammar
src/
  routes/                  File routes: _site (public), _app (workspace), api
  components/              Workspace shell, panes, docs, site, ui (shadcn)
  lib/                     Workspace store, content, search, maths, graph engine, data
  server/                  Server functions and server-only helpers (*.server.ts)
  db/schema.ts             Drizzle schema for D1 (migrations in drizzle/)
scripts/check-notes.ts     The notes:check command

Routes#

RouteFilePurpose
/, /subjects, /docssrc/routes/_site*The public site: landing page, subject pages, documentation
/appsrc/routes/_app/app.tsxWorkspace home
/notes/<id>src/routes/_app/notes/$.tsxA note, server-rendered
/tools/<kind>src/routes/_app/tools/$kind.tsxA shareable URL for a tool tab
/api/ai/chatsrc/routes/api/ai/chat.tsStreaming tutor chat (refuses while AI is off)
/api/admin/reindexsrc/routes/api/admin/reindex.tsRebuild the semantic search index

The root route (src/routes/__root.tsx) provides auth, data, tooltips and toasts to everything. The _app layout renders the workspace shell: sidebar, split panes, command palette and global shortcuts.

Content pipeline#

Notes are compiled once, at build time, by vite/notes-plugin.ts. The client never fetches Markdown or runs KaTeX for notes.

content/notes/**.md
  -> gray-matter + Zod (NoteDocument)       frontmatter validated
  -> remark: GFM, maths, directives
  -> remarkGraphs                           ```graph fences -> <figure data-graph>
  -> remarkBlocks                           :::example, :::key ... -> styled blocks
  -> rehype: raw HTML, heading slugs, KaTeX (+ mhchem), Shiki
  -> headings and plain text collected      for the table of contents and search
  -> HTML string

The plugin produces one small module and a set of plain files:

OutputContentsLoaded
virtual:notes/indexSubjects, courses and metadata for every noteBundled; small
/content/notes/<id>.jsonOne note: id, title, course and rendered HTMLOn demand
/content/search.jsonPlain-text bodies for the search indexWhen search is first used

The JSON files are written into the client build, where Workers Static Assets serves them, so note content never counts towards the Worker's size limit. Everything reads them through src/lib/notes/source.ts (loadNoteHtml(id), loadSearchIndex()): the browser fetches them, and server rendering reads them through the ASSETS binding, so note pages still arrive with their content. Under pnpm dev the plugin serves the same paths from memory. To move content elsewhere, such as R2, only that loader changes.

After the workspace loads, every note is prefetched in idle time, so switching tabs never waits on the network. Graph figures are drawn on the client by NoteGraph from the source kept in data-graph.

While compiling, the plugin warns about invalid frontmatter, links to missing notes, notes missing from a course manifest, broken graph blocks and embedded images. Courses with no notes yet are left out of the index, so the app only shows what exists. In development the plugin watches content/ and reloads the page when a file changes.

The docs use the same remark and rehype plugins through vite/docs-plugin.ts, so every block, formula and graph in a note can be shown in the docs exactly as it renders in the app.

Workspace state#

Tabs and panes live in a TanStack Store in src/lib/workspace.ts:

interface WorkspaceState {
  groups: Group[]        // panes, left to right; each has tabs and an active tab
  focused: string        // the pane new tabs open in
  sidebarOpen: boolean
  sidebarWidth: number
  sizes: number[]        // fractional pane widths, summing to 1
}

The state is persisted to localStorage and restored on load. The URL mirrors the active tab of the focused pane in both directions: notes and home are real routes and are server-rendered; tool tabs are client-only and get /tools/<kind> URLs so they can be shared.

Panes talk to each other through a small buffered message bus, src/lib/inbox.ts. For example, Add to sheet sends an add-to-sheet message and opens the cheat sheet tab; if the cheat sheet has not mounted yet, the message waits until it subscribes.

send('add-to-sheet', { noteId, latex, text })
openTab({ kind: 'sheet' })

Data and sync#

User data is defined once as Zod schemas in src/lib/data/schemas.ts and mirrored as Drizzle tables in src/db/schema.ts. The tables are: sheet items, decks, cards, reviews, topic progress, study events, plan tasks, exams, user notes, question sets and conversations (the last two belong to the AI features). Every row has id, userId, createdAt and updatedAt, with timestamps as epoch milliseconds.

Components read and write through TanStack DB collections from useData(), never through server functions directly. The collections come in two modes with one API:

ModeWhenStorage
localSigned out, or accounts not configuredlocalStorage, one key per table
remoteSigned inTanStack Query collections, synced to D1

In remote mode, inserts, updates and deletes apply optimistically and call the server functions in src/server/sync.ts (listRows, upsertRows, deleteRows). Every call is scoped to the Clerk user and validated against the shared schema. On first sign-in, migrateLocalToRemote copies guest rows into the account and clears them locally.

Server code#

Server functions are created with createServerFn. Anything that must never reach the browser (the Anthropic SDK, cloudflare:workers, Clerk's server helpers, request headers) lives in *.server.ts files and is loaded with a dynamic import() inside the handler, so the module that defines the server function stays safe to import from components.

export const semanticSearch = createServerFn({ method: 'GET' })
  .validator(z.object({ q: z.string().min(2).max(300), limit: z.number().int().min(1).max(20).default(8) }))
  .handler(async ({ data }) => {
    const { env, embed } = await import('./semantic.server')
    const e = await env()
    // ...
  })

pnpm build fails if server-only code leaks into a client bundle.

AI#

Every AI feature (the tutor, Explain with AI, AI flashcards, the photo scan in My notes and homework suggestions for teachers) is switched off by one flag, AI_ENABLED in src/config/ai.ts. It is false because there is no AI provider Margin can offer every student yet. The code stays in place, and changing that one line brings it all back.

  • The UI. Every AI entry point checks the flag and is hidden while it is off: the ai tool and tab kind, the tutor and ask items in ⌘K, the AI items in the selection toolbar and right-click menu, Make flashcards, the scan buttons and the marketing copy. Docs pages about AI features take ai: true in their frontmatter and are left out of the docs build.
  • The guard. Every AI server function (src/server/ai.ts) and the streaming chat route (/api/ai/chat) get their provider from resolveProvider (src/server/ai/provider.server.ts), which calls requireAiAccess in src/server/ai/access.server.ts. While the flag is off it throws not_configured, so the rule is enforced on the server, not only hidden in the UI. A future end-user provider plugs in at requireAiAccess.
  • Client state. useAiAccess() (src/lib/ai/use-ai-access.ts) reports the server's decision; AI panes are wrapped in AiGate (src/components/ai/AiLocked.tsx). Copy lives in src/lib/ai/copy.ts.
  • Development. With the flag on, MARGIN_DEV_SITE_AI=1 and ANTHROPIC_API_KEY, vite dev answers with Claude through the official Anthropic SDK, rate-limited per hour in KV. Production builds ignore it.

Semantic search (below) uses Workers AI embeddings but is not an AI feature in this sense: it is not affected by the flag.

Accounts and onboarding#

Clerk is the account system. The sign-in and sign-up pages are custom (src/components/auth/AuthPage.tsx) on Clerk's signal hooks: Google OAuth and email codes. The _app layout sends signed-in users without a finished profile to /onboarding. Profiles (role, year, courses, school, subjects, onboardedAt) are one row per user in the profiles table, read and written through src/server/profile.ts and useProfile() in src/lib/profile.ts; guests keep theirs in localStorage. Home, the sidebar and ⌘K put the profile's courses first.

  • Lexical search (src/lib/search.ts) builds a MiniSearch index over titles, headings and body text in the browser, in the background when the workspace loads. It is synchronous, so results update on every keystroke.
  • Semantic search (src/server/semantic.ts) embeds the query with Workers AI and queries Vectorize. Results are merged under Related in the palette. The index is built by POST /api/admin/reindex.

The grapher#

The grapher is self-contained in three modules:

ModuleRole
src/lib/math/expr.tsTokenizer, precedence-climbing parser and compiler to fast closures
src/lib/graph/engine.tsClassifies a statement into a plot kind; samples curves; marching squares for implicit curves; points of interest
src/lib/graph/render.tsCanvas renderer: grid, axes, monochrome line styles, shading

src/lib/notes/graph-block.ts compiles ```graph fences on top of the same engine. See Grapher syntax.

Design system#

Every surface is built from the shadcn/ui components in src/components/ui and lucide icons, on a strict neutral monochrome token set in src/styles.css. There are no accent colours: things are distinguished by weight, shade and dash pattern. Dark mode is the .dark class on <html>.

Search documentation

Search every page of the Margin docs