The repo is a pnpm workspace with Turborepo orchestration. The root is unusual: it doubles as the
public website app and the monorepo root. This is a deliberate trade-off to avoid moving
~200 unchanged files into apps/web/ — both apps deploy fine from this layout and the rename
becomes a cosmetic follow-up.
website/ (repo root = the public Next.js app, will become @matters/web)
├── apps/
│ ├── workspace/ @matters/workspace — workspace.matters.ai
│ ├── webdocs/ @matters/webdocs — webdocs.matters.ai (this site)
│ ├── memo/ @matters/memo — Astro app (not Next.js)
│ ├── partners/ @matters/partners — partners portal
│ ├── static/ @matters/static — static assets app
│ ├── weblabs/ @matters/weblabs — experimental sandbox
│ └── sales-funnel/ stub (no package.json yet)
├── packages/
│ ├── auth/ @matters/auth — email allowlist (+ RBAC migration target)
│ ├── lib/ @matters/lib — env, errors, logger, sentry config, cn
│ ├── ui/ @matters/ui — scaffold for shared Radix primitives
│ ├── theme/ @matters/theme — runtime multi-variant theme system
│ └── config/ @matters/config — shared tsconfigs
├── pnpm-workspace.yaml
├── turbo.json
├── package.json (root: also the public Next.js web app for now)
└── app/, components/, lib/, hooks/, public/, … (the production website code)
Defined in pnpm-workspace.yaml:
packages:
- "apps/*"
- "packages/*"
The root package.json is not itself a workspace member — it lives outside the packages:
glob — but it can still depend on workspace packages via the workspace:* protocol:
{
"dependencies": {
"@matters/auth": "workspace:*",
"@matters/lib": "workspace:*"
}
}
apps/workspace/tsconfig.json adds path aliases that point UP into the repo root so workspace
files can still write import x from "@/lib/auth/rbac":
"paths": {
"@/lib/*": ["../../lib/*"],
"@/components/*": ["../../components/*"],
"@/hooks/*": ["../../hooks/*"],
"@/assets/*": ["../../assets/*"],
"@/types/*": ["../../types/*"],
"@/app/*": ["../../app/*"],
"@/styles/*": ["../../styles/*"],
"@/public/*": ["../../public/*"],
"@/site.config": ["../../site.config"],
"@/menu.config": ["../../menu.config"],
"@/*": ["./*"]
}
This is a transitional state: as modules graduate into packages/*, the corresponding
alias entries are dropped, until eventually apps/workspace has no cross-app paths.
proxy.ts rule#Most Next.js apps in this monorepo run Next.js 16.1.6; apps/partners is on 16.2.9.
apps/memo is Astro (not Next.js) — it uses Astro's own middleware naming, see
Memo → Architecture, and is unaffected by everything
on this page.
Next.js 16 renamed middleware.ts → proxy.ts. Both files cannot coexist in the same app:
✗ Error: Both middleware file "./middleware.ts" and proxy file "./proxy.ts" are detected.
Please use "./proxy.ts" only.
That's exactly the build break that took out a Vercel deploy in this repo recently.
proxy.ts (root or per-app). Never middleware.ts. (apps/partners is the lone exception still on middleware.ts — not yet migrated.)proxy.ts always runs on Node.js. Do NOT add export const runtime = "nodejs" — Next 16 fails with "Route segment config is not allowed in Proxy file." Same goes for any other route-segment-config exports.export default <handler> (the request handler, typically wrapped in clerkMiddleware(...)).export const config = { matcher: [...] } (the path filter).| App | File |
|---|---|
Root (public site, matters.ai) | proxy.ts |
| Workspace | apps/workspace/proxy.ts |
| Webdocs | apps/webdocs/proxy.ts |
| Weblabs | apps/weblabs/proxy.ts |
| Static | apps/static/proxy.ts |
| Partners | apps/partners/middleware.ts (not yet migrated) |
The root proxy.ts is the most complex of the five and is wrapped in clerkMiddleware(...). Inside the wrapped handler, in order:
ALLOWED_HOSTS (matters.ai, www.matters.ai, beta.web.matters.ai) with a 404. Kills preview deployments and rogue CNAMEs./internal-only-do-not-fetch, /admin-panel-private, /scrape-trap. Any client fetching these is by definition a scraper; immediate 403./api/* so legit server-to-server traffic still works)./api/auth/2fa, /api/datasheets/admin, /api/legal, etc. return 404 when served from the public host; same path on workspace.matters.ai proceeds normally and is auth-gated by apps/workspace/proxy.ts./api/:path* requests run through enforceRequestRateLimit (MongoDB-backed; works because proxy.ts is Node by default).next.config.ts.)If a future Next release changes the proxy convention again:
grep -rn "middleware.ts\|proxy.ts" --include="*.md*" --include="*.ts" to enumerate references.pnpm build against every Next app: root, apps/workspace, apps/webdocs, apps/weblabs, apps/static, apps/partners.pnpm install # workspace-aware
pnpm dev # root (matters.ai) on :4000
pnpm --filter @matters/workspace dev # workspace on :4001
pnpm --filter @matters/webdocs dev # docs on :4002
pnpm run type:check # root typecheck (only the root app)
pnpm --filter @matters/workspace type:check
pnpm run workspace:build # turbo build (all apps + packages)
pnpm verify # green-light gate: see "Verify pipeline" below
pnpm verify at the repo root is the monorepo-wide green-light gate. Run it before every push.
pnpm verify
It chains two steps:
turbo run type:check — runs type:check on every workspace package that declares one. Today that's all six Next.js apps (root via tsc --noEmit; per-app via apps/<name>/package.json — apps/workspace, apps/webdocs, apps/weblabs, apps/static, apps/partners), the memo Astro app (via astro check), and the typed shared packages (@matters/lib, @matters/ui, @matters/auth, @matters/config). Turbo caches per-package results, so a clean verify after a no-op edit takes <1 s.
pnpm --filter @matters/memo verify — the memo-specific composite: type:check (already covered above, runs again to wire into the dependency chain) + Vitest (135 unit tests across 10 files at last count) + astro build + scripts/verify-routes.mjs (canonical route↔file parity check — 134 routes / 134 files at last count). Documented in detail at Memo → Operations → Verify Pipeline.
Total runtime: ~10–15 s cold, sub-second when turbo's cache is warm.
The memo Astro app has invariants no other app has — visitor URLs at the root, the SLUG_RESERVED ↔ isVisitorPath parity rule, the meta-tag based beacon contract, the canonical route↔file parity map — so it gets its own deep gate. The Next.js apps share a more uniform tsc --noEmit shape and are caught by the turbo layer.
| App | Has type:check | Has test | Has verify | Notes |
|---|---|---|---|---|
Root (matters-ai) | ✓ (tsc --noEmit) | ✓ (jest, currently smoke-only) | — | Run pnpm type:check && pnpm build for now |
@matters/workspace | ✓ | — | — | |
@matters/webdocs | ✓ | — | — | |
@matters/weblabs | ✓ | — | — | Added in the same pass that introduced root verify |
@matters/static | ✓ | — | — | |
@matters/partners | ✓ | ✓ | — | On Next.js 16.2.9; still uses middleware.ts |
@matters/memo (Astro) | ✓ (astro check) | ✓ (vitest, 135 tests) | ✓ | Full chain incl. verify:routes |
If you want vitest in a Next.js app the same way memo has it:
vitest to devDependencies of the app's package.json.vitest.config.ts next to the app's tsconfig.json with @/* alias resolution.*.test.ts next to the modules they cover; keep them to pure-function scope (no DB / no Next handlers)."test": "vitest run" to the app's package.json.verify script (or add one) to include test in the chain.verify then picks it up automatically via turbo run type:check because turbo respects per-package script chains via turbo.json — add test to the pipeline list if you want it cached too.apps/web/?#When the workspace was carved out (slice 3), moving the public website into apps/web/ would
have meant moving 200+ files that didn't otherwise need to change. Keeping the public site at
the repo root means:
@/components/...) keep working.apps/web/ rename can happen later as a single mechanical commit.The trade-off: the root package.json mixes monorepo tooling (turbo) with app deps. That's
fine — pnpm doesn't care, and dev tools (eslint, prettier) inherit cleanly.