Skip to content

Technical documentationInternal

For the team and anyone taking the product over. Not linked from the site and not indexed.

Technical documentation pages

Architecture

Drybench is one Next.js App Router application that serves a public site, the app workspaces and a small set of server routes from the root of its own origin. Almost everything it needs from the framework is generated from the app registry, so adding an app rarely touches routing or configuration code.

Stack

The versions below are the pins in package.json. Node 22.18 or later is required (engines in package.json, and .nvmrc).

Layer Package Version
Framework next 16.3.8
UI react, react-dom 19.2.8
Styles tailwindcss, @tailwindcss/postcss ^4
Language typescript ^5
Lint eslint, eslint-config-next ^9, 16.3.8
Browser tests @playwright/test, axe-core 1.56.1, 4.12.1
Structure viewer molstar (Mol*) 5.11.0
Cheminformatics @rdkit/rdkit (RDKit.js) 2026.3.6
3D molecule viewer 3dmol (3Dmol.js) 2.5.5
Structure editor, conformers openchemlib 9.25.0
Accounts and backups @supabase/ssr, @supabase/supabase-js ^0.12.7, ^2.117.2
Billing stripe ^23.0.0
Other runtime @floating-ui/dom, fuse.js, fast-xml-parser, uqr, server-only see package.json

This version of Next.js differs from older releases. AGENTS.md asks you to read the relevant guide in node_modules/next/dist/docs/ before writing framework code; the route ordering described below comes from node_modules/next/dist/docs/01-app/03-api-reference/05-config/01-next-config-js/rewrites.md.

Two path aliases are defined in tsconfig.json: @/* resolves to src/* and @content/* to content/*. Modules that Node tests, scripts or next.config.ts import directly use relative .ts imports instead, because Node does not resolve the aliases. The unit tests run on Node's built-in type stripping, so ESLint (eslint.config.mjs) rejects enums, namespaces and parameter properties. See Build, test and CI.

Directory layout

Path What lives there
content/ The registry (content/apps.ts), product names (content/app-names.ts), all user-facing copy, the route map (content/apps/routes.ts), the origin and maker details (content/apps/maker.ts), the CSP generator (content/csp.ts), redirects and cache rules (content/redirects.ts) and the config half (content/apps/next-config.ts). See Registry and content.
src/app/ App Router routes, grouped as described below.
src/apps/<slug>/ Each app's AppDefinition, its entry chunk (entry.tsx), workspace views, object tabs, Send to rules and page ids. Shared maps over every app (definitions.ts, objects.ts, object-tabs.tsx, page-ids.ts, tasks.ts, explainers.ts) sit at the top of src/apps/. src/apps/education/ holds the education workspaces.
src/components/suite/ The shared shell, primitives, theme, store, network layer, export writers, design tokens (tokens.css, tokens.ts), generated accents (accents.css) and motion.
src/components/tools/<slug>/ Each app's domain code and panels.
src/components/showreel/ The landing page's intro reel.
src/lib/ Modules shared by apps and routes, for example workspace/ (Studies, backups, billing policy), handoff/ (cross-app link parameters), sharing/, search/, literature/, seq/, fit/, units/, dilution/, hosts/ (the bioreactor's host-cell model, not network hosts), vendor.ts and load-script.ts.
pipeline/<slug>/ Data snapshot, build and verification scripts per app. See Data pipeline.
scripts/ Build steps and checks: vendor.mjs, notices.mjs, prebuild.mjs and prebuild/, postbuild.mjs and postbuild/, app-index.mjs, accents.mjs, licences.mjs, size-check.mjs, check-boundary.mjs, snapshot age checks.
e2e/ Playwright specs, grouped by app and by suite area (smoke/, marketing/, objects/, perf/ and others).
supabase/migrations/ SQL migrations for the account and backup database. See Server and accounts.
public/ Static files: data/<slug>/ (data files the apps fetch from the same origin), data/search/ (search catalogues), media/, tools/, verification/<slug>/ (published verification sets). public/vendor/ and public/third-party-notices.txt are generated at build time and gitignored.

Route groups

All routes live under src/app/. Route groups (folders in parentheses) do not appear in addresses; they exist to give each family of pages its own layout. The one root layout, src/app/layout.tsx, owns <html>, <body>, the fonts, globals.css, the skip link and PolicyNavigationBoundary.

<segment> below is an app's URL segment from APP_SEGMENTS in content/apps/routes.ts (for example protein, molecule, calculators), never its slug or product name.

Group or folder Addresses Layout and purpose
(marketing) /, /<segment>, /docs, /docs/<segment>, /security, /regulatory, /changelog, and their opengraph-image routes MarketingScope and SuiteChrome: the suite header and footer inside the shared theme scope. landing-data.ts builds the landing export.
(tasks) /<segment>/<task>, /<segment>/explainers, /<segment>/explainers/<id> Same chrome as marketing (with prefetchNavigation={false}). Task pages take user input and get their app's security headers; explainers take none and get the site's.
(workspace) /<segment>/app, /<segment>/app/<page>, /<education-id>/app (the (education-tools) sub-group), /o, /o/<type>/<id>, /o/mine/<type>/<uuid>, /study/<id>, /literature/app, /workspace, /workspace/auth/callback, /dev-kit No marketing chrome and no analytics. The group layout sets robots: { index: false } and wraps pages in data-suite.
api/ /api/literature/*, /api/search/*, /api/share-image, /api/study/summary, /api/workspace/* (access, auth, backups, billing, sign-out) Route handlers. See Server and accounts and Security and network.
share/ /share/<app>/<subject> A card page for link previews; a browser is sent on to the original view with its fragment intact.
landing.json/ /landing.json The landing page as data, for the copy on mbio.tech. See Registry and content.
(technical) /technical, /technical/<page> This technical documentation (D-0052; no password since D-0081), rendered from docs/technical/*.md and listed in content/technical-docs.ts. src/proxy.ts sets its headers.
(methods) /methods, /methods/levels, /methods/<segment>, and each with /index.md Every app's methods and checks (D-0085), public: the Methods dialog's content with every group expanded, the methods first and the limits last, what the levels mean on its own page under the overview (/methods/levels, a static segment beside [app]), and without source paths, implementation versions, expected-value files or the reference packages' table (the CEO's instructions of 14 October 2026), built at build time as Markdown (src/lib/methods/markdown.ts, from each app's sheet) and rendered with the technical documentation's renderer into the same frame and prose styles ((methods)/methods/frame.tsx, technical-prose.css), with a canonical address, a text/markdown alternate and TechArticle structured data. The index.md routes serve the same Markdown with absolute links. Listed in the sitemap; indexable once SUITE_INDEXABLE=1 (D-0026).
llms.txt/ /llms.txt The suite for language models (llmstxt.org): two sentences, then the methods overview, the levels page and every app's methods Markdown; the docs pages are not listed (llmsTxt in src/lib/methods/markdown.ts).
robots.ts, sitemap.ts /robots.txt, /sitemap.xml Robots allows everything; the sitemap lists the public pages from suiteSitemapPaths() in content/apps/pages.ts, the methods pages included.

The dynamic [app] segment appears in three groups. The workspace route's static app segment wins over the task route's dynamic [task], so /<segment>/app is never taken for a task page. scripts/postbuild/tasks-routes.mjs proves this from Next's build manifests after every build. Task ids that would collide with a route are refused by the registry test (RESERVED_TASK_IDS in content/apps.ts).

The dev kit (/dev-kit, /dev-kit/workspace) calls notFound() unless the build ran with SUITE_KIT=1. It is not in the sitemap or robots file.

Static and dynamic rendering

What each route declares, as read from the route files:

Route Declaration Effect
/<segment>/app and /<segment>/app/<page> generateStaticParams over APP_SLUGS (and pageIdsFor), dynamicParams = false Prerendered for every launch app; any other segment or page is a 404.
/<segment>, /docs/<segment> generateStaticParams, dynamicParams = false Prerendered per launch app.
/<segment>/<task>, explainer routes generateStaticParams from allTasks() and the explainer list, dynamicParams = false Prerendered per registry entry. Task pages never read searchParams; input travels after the # and is read in the browser.
/landing.json dynamic = "force-static" Built with the site.
/workspace dynamic = "force-dynamic" Rendered on request, because it reads the server's account configuration.
/api/workspace/access, /api/workspace/backups dynamic = "force-dynamic" Rendered on request.
Several /api/* handlers runtime = "nodejs" Run on the Node.js runtime.
/o/<type>/<id>, /o/mine/..., /study/<id>, /share/... No generateStaticParams Nothing is prerendered for these parameters. Object and study pages read their records from the browser's store on the client; the server only builds metadata.

The workspaces are prerendered without a theme or any user state. Everything a person works on is read in the browser, from local storage, IndexedDB and the URL fragment. See Storage and state.

How a request is served

next.config.ts is a thin wrapper. It imports four functions from content/apps/next-config.ts and passes them through, so no app edits the config:

Config key Source What it produces
env suiteEnv(phase) NEXT_PUBLIC_BUILD_HASH, NEXT_PUBLIC_BUILD_AT (from scripts/lib/build-info.mjs) and NEXT_PUBLIC_SUITE_ORIGIN (from suiteOrigin() in content/apps/maker.ts). A production build throws if the hash resolves to the placeholder dev.
headers() suiteHeaders(phase) In order: X-Robots-Tag: noindex on every path unless SUITE_INDEXABLE=1 (noindexRules, D-0026); immutable caching for /vendor/*; ten minutes fresh plus a day of revalidation for /data/*; the education workspaces under /<education-id>/app/*, which reuse Cell's policy (headersFor("inside-a-cell")); suite and per-app cache rules (appCacheRules); each workspace's CSP and security headers (appHeaderRules); each app's task pages (taskHeaderRules); the object, study and literature pages (objectPageHeaderRules).
rewrites() suiteRewrites() beforeFiles: every app's own rewrites (none today). afterFiles: the stable /<segment>/opengraph-image address rewritten to the hashed name Next gives a metadata route inside a route group (groupedMetadataSuffix).
redirects() suiteRedirects() legacyRedirects() in content/redirects.ts: permanent redirects from the old /apps/... addresses on the maker's site and each carried-over app's /tools/<slug> page, plus any app's own legacyRedirects. The retired /pricing and /apps/pricing addresses redirect directly to /; the pricing page and social image were removed at the CEO's request on 25 October 2026, along with its footer link and sitemap entry.

The config also sets turbopack.root and outputFileTracingRoot to the project folder, so a lockfile in a parent folder is never taken for the workspace root. It also sets outputFileTracingIncludes so the /technical route can read docs/technical/**/*.md at request time.

A request is handled in Next's documented order: headers, then redirects, then the proxy, then beforeFiles rewrites, then static files and non-dynamic pages, then afterFiles rewrites, then dynamic routes. In practice:

  1. A request to an old address such as /apps/protein/app matches a legacy redirect and receives a permanent redirect to /protein/app. Query strings pass through, and the browser keeps any #s= fragment.
  2. A request to /protein/app matches the workspace header rule /<segment>/app/:path* (workspaceRouteSource) and is served the prerendered workspace HTML with the app's own Content-Security-Policy, Referrer-Policy: no-referrer, Permissions-Policy, X-Content-Type-Options and Cross-Origin-Opener-Policy headers (headersFor in content/csp.ts).
  3. The browser parses the inline no-flash theme script, then hydrates SuiteWorkspace, which loads the app's entry chunk and renders it inside AppShell.
  4. The app reads its state from the URL fragment and the browser's store, and contacts only the hosts its registry entry declares. The CSP connect-src and the shell's network allow-list (configureNet({ hosts: hostsOf(app) }) in AppShell) are generated from the same dataPolicy.endpoints list.

src/proxy.ts is the only proxy, and its matcher covers /technical alone: it lets every request through with that area's own security headers (no shared caching, noindex, a policy with no third-party hosts); the area has asked for no password since D-0081. Every other address passes through untouched.

Landing, product, docs, security, regulatory, changelog and explainer pages get no CSP; the tests in content/csp.test.ts check this. The reasoning behind the policy (static generation forces 'unsafe-inline' scripts; 'wasm-unsafe-eval' only for RDKit; 'unsafe-eval' only where a library's entry gives a reason) is in the header comment of content/csp.ts and design system §11.5 (docs/design/suite-design-system.md). See Security and network.

A CSP belongs to the loaded document, not to a client-side route change. PolicyNavigationBoundary (src/components/suite/navigation/policy-navigation-boundary.tsx) intercepts same-origin link clicks and, when needsDocumentNavigation() says the destination is in a different policy area, performs a full document load (window.location.assign) instead of a Next client navigation, after flushPendingWorkspaceWork() has finished any registered saves. The areas are defined by documentPolicyBoundary() in src/components/suite/navigation/document-policy.ts: /o and /study share one area, each /<segment>/app tree (and each education workspace) is its own area, every other path under an app segment is its own area, and everything else is public. The router hook usePolicyRouter (use-policy-router.ts, same folder) applies the same rule to push and replace, which the command palette and hand-offs use.

The workspace and the shared shell

src/app/(workspace)/[app]/app/page.tsx resolves the segment to a slug, then renders WorkspaceFrame (workspace-route.tsx): the no-flash ThemeScript, a ThemeScope, a <noscript> message and SuiteWorkspace.

SuiteWorkspace (src/apps/definitions.ts) is a client component holding one loader per app. Each loader is a next/dynamic import of src/apps/<slug>/entry.tsx, which hands the app's AppDefinition and its full registry entry to AppShell through a render prop. The shared workspace route therefore carries the shell and the light client index but no app's entry or definition; an app's code downloads only on its own workspace. While the chunk loads, ShellSkeleton (built from the light index) stands in where no server HTML exists. Each entry sets a displayName such as drybench-entry:plasmid-map, which scripts/size-check.mjs uses to find the chunk for its size budget.

AppDefinition (src/components/suite/app-definition.ts, design system §11.3) carries what only code can: the tool root (Main), the rail, the inspector, commands, keys, storage kinds, the search field, the share link and the app's sections of Settings and About. Everything the pages and the CSP read is in the registry instead.

AppShell (src/components/suite/shell/app-shell.tsx) is the frame every app lives in: the top bar, notices, the grid of rail, main and inspector, the status bar with the one polite live region, toasts, the layers element and the shell's overlays (command palette, shortcuts, Settings, About and licences, Limits, coach marks, the phone menu). It has a full mode for workspace pages and a preview mode for the live preview on product pages, which drops landmarks, global keys, persistence, sharing and the app switcher.

The suite's own workspace pages (Bench at /o, object pages, study pages, Literature and the education tools) use SuiteFrame (src/components/suite/objects/suite-frame.tsx) instead, with the same top bar, Research sidebar, menu sheet and status bar (D-0028). Object pages render other apps' tabs through src/apps/object-tabs.tsx, which loads each tab and its app's full entry only when the tab opens.

Public pages render inside MarketingScope and SuiteChrome (src/components/suite/marketing/). Files named *.marketing.tsx render on the server for marketing and task pages and are exempt from the client registry import rule.

Heavy engines

Mol*, RDKit.js and 3Dmol.js are not bundled. They are served as static files and loaded only when a view needs them.

Step Where What happens
Copy scripts/vendor.mjs (npm run vendor, run by predev and prebuild) Copies the listed build files from node_modules into public/vendor/<name>@<version>/, with each package's licence text as LICENSE.txt. RDKit's npm package ships no licence, so its text is kept in scripts/licenses/rdkit-LICENSE.txt. Builds of other versions are deleted. The script refuses a package that is not pinned to an exact version in package.json.
Paths src/lib/vendor.ts vendorVersions and the vendor URL map (molstarJs, molstarCss, rdkitJs, rdkitWasm, threeDmolJs). src/lib/vendor.test.ts fails if a version drifts from package.json.
Caching suiteHeaders /vendor/* is served public, max-age=31536000, immutable, which is safe because the version is in the path.
Loading src/lib/load-script.ts loadScript and loadStylesheet add a tag once per page however many callers ask, reject after a timeout (60 s by default) and forget a failed load so the next call retries.
Licences scripts/licences.mjs, scripts/notices.mjs, scripts/lib/licence-texts.mjs The licence check verifies each vendored package's licence and that its text exists; notices.mjs writes public/third-party-notices.txt for the production dependencies, the code bundled inside the vendored builds and the self-hosted fonts; both read which text travels with what from licence-texts.mjs.

Current callers:

  • Mol*: src/components/tools/protein-explorer/molstar-view.tsx and src/components/tools/inside-a-cell/molstar-viewer.tsx, through loadStylesheet and loadScript.
  • RDKit.js: src/components/suite/engines/rdkit/rdkit.worker.ts, a Web Worker that calls importScripts(vendor.rdkitJs) and passes vendor.rdkitWasm to initRDKitModule on its first request. The worker closes itself after an RDKit failure and the page starts a fresh one.
  • 3Dmol.js: src/apps/molecule-workbench/panels/shape-panel.tsx, through loadScript.

An app that runs WebAssembly sets dataPolicy.wasm: true in its entry to get 'wasm-unsafe-eval'. Mol* needs 'unsafe-eval' and a data: connection, declared with reasons in dataPolicy.cspAllowances.

Theming

One appearance preference covers every page (D-0029). The key is suite:theme (THEME_STORAGE_KEY in src/components/suite/theme/preference.ts); the values are light, dark and system; the default is light (DEFAULT_PREFERENCE, D-0035).

  • Before first paint. Pages are prerendered without a theme. ThemeScript (src/components/suite/shell/theme-script.tsx, design system §3.3) is an inline script emitted before the page markup. It sets data-theme on <html>, resolving system with prefers-color-scheme. It also reads suite:settings to set data-motion="reduce", data-density="compact" and data-text="large", and on workspaces applies layout hints (data-rail, --inspector-saved). Every storage read is guarded, so blocked storage gives the default. The root layout sets suppressHydrationWarning on <html> for this reason.
  • Where it is emitted. WorkspaceFrame, MarketingScope (marketing and task pages, with layout hints off), the /o, /study and /literature pages, and the education layout.
  • After hydration. ThemeScope (src/components/suite/theme/theme-scope.tsx) and the theme store (src/components/suite/theme/store.ts) keep the attribute current through client navigation, OS changes and other tabs (the storage event), and dispatch the suite:theme window event that charts and canvases listen to. They also update the theme-color meta tags.
  • Migration. A valid old per-scope choice (suite:<slug>:theme, or drybench:landing-tone for the suite) seeds the shared key once.
  • Per-app limits. An app whose registry themes lacks "dark" always resolves to light. All ten apps list both themes today.
  • Colours. Components use semantic tokens (src/components/suite/tokens.css, mirrored in tokens.ts). The ESLint rule suite/no-brand-colour refuses brand colour utilities and raw hex in src/components/suite/** and src/apps/**. App accents are generated: scripts/accents.mjs (npm run accents) writes src/components/suite/accents.css from ACCENTS and APP_IDENTITY, and data-app="<slug>" on the shell root selects the triple. src/components/suite/tokens.test.ts fails when the committed CSS is stale.

Intro and guided-walk defaults (16 October 2026)

The homepage’s pre-paint showreel bootstrap checks suite:home-intro-seen in localStorage and records it when playback begins. Returning visits show content immediately; first-visit Skip/Esc and reduced-motion handling remain. Explicit ?showreel preview/capture links still allow replay. Storage refusal is caught so content remains reachable.

Guided walks default to collapsed step text after step 1 below 768 px. Show step text, Next, Leave and persisted Paused state remain available. A changed-input warning always expands because it explains blocked progress. The compact heading retains its full accessible text and title.

Public reference workflows (16 October 2026)

The static reference entries reuse SuiteWorkspace with selected initial inputs, server-rendered articles and build-time social cards. No new service or database is introduced. See Public reference workflows for the shared route, state, source and export contracts.