Engineering conventions
How NXT works as a codebase. Conventions, patterns, what to enforce, what to leave alone
The architectural decisions that aren't obvious from reading the code.
Onboarding sequence
- Clone, install, run. See Local setup. If
pnpm checkis clean andpnpm iosboots, the environment is correct. - Read
CLAUDE.md+AGENTS.mdat repo root. Single source of truth for engineering conventions. Both files are generated from.ruler/*.mdand regenerated viapnpm ruler:apply. - Read
packages/backend/CLAUDE.mdfor Convex-specific guidance and the link to Convex's AI tooling rules. - Walk the
.runbooks/directory. Operational playbooks. Each one is the answer to "how do I X" for things that come up often enough to need notes. - Read
docs/v1-to-v2-summary.mdfor the client-facing version of the architectural change. Useful for explaining the matching engine to non-engineers. - Open the Convex dashboard and look at: the cron list, the recent function logs, the schema, the storage usage.
The mental model
NXT is built on six conventions that, if respected, keep the codebase healthy. If broken, the codebase rots fast.
1. Wrapped Convex factories
Never use raw query() / mutation() / action(). Use userQuery / userMutation / userAction (resolve ctx.user from WorkOS session) or internalQuery / internalMutation / internalAction (for cron + workflow targets).
Why: raw factories are publicly callable. A mutation() that "only the admin should use" is a parameter-spoofing vulnerability waiting to happen. Lint enforces.
2. Ownership index on every user-scoped table
Every table that holds user data has an index whose first field is userId (or the org-scoped equivalent). The user-internal cascade query for account deletion depends on this.
Why: without it, account deletion would have to scan whole tables. With it, deletion is O(per-user rows).
3. Validators live in one place
packages/backend/convex/lib/validators.ts is the canonical place for column validators. Re-declaring an inline validator with the same shape can trigger TS2589 ("type instantiation is excessively deep"). Always check the validators file first.
4. Error factories, not raw throws
Backend errors are constructed via convex/lib/errors.ts (base) or convex/lib/errors-<feature>.ts (feature-specific). Each factory stamps code + English message + messageKey. The lint rule custom/no-raw-convex-throw blocks raw throw new ConvexError(...) or throw new Error(...).
Adding a user-facing failure mode:
- Add
errors.<key>topackages/i18n/locales/en.json. - Add a factory function in
convex/lib/errors-<feature>.ts. - Call it. Done.
5. Every user-facing string goes through i18n
No hardcoded JSX text, no inline Text children, no inline alert messages. pnpm i18n extracts keys; pnpm i18n:lint flags violations. Web uses next-intl; mobile uses i18next. Both pull from one flat packages/i18n/locales/en.json.
i18n call shape: full dotted key, no namespace argument. t("section.key"), not getTranslations("section"). The custom/no-namespaced-i18n rule enforces this.
6. The frontend translates backend errors
Every mutation/action catch surfaces backend errors via:
import { getErrorMessage } from "@app/core/errors";
// ...
catch (err) {
toast.error(getErrorMessage(err, (k, p) => t(k, p)));
}getErrorMessage resolves the backend's messageKey through the i18n catalog. Raw err.message leaks English copy.
For inline field errors, branch on parseConvexError(e).code === "VALIDATION_FAILED". For auth: branch on AUTH_REQUIRED / FORBIDDEN and redirect.
What the lint configuration actually catches
The eslint.config.mjs enforces:
@convex-dev/no-collect-in-query—.collect()only on bounded sets (allow-listed exceptions).@convex-dev/no-filter-in-query— no.filter()after.withIndex()unless allow-listed.custom/no-raw-convex-throw— backend errors must use a factory.custom/no-namespaced-i18n—getTranslations()/useTranslations()must take no argument.react-compiler/react-compiler— React Compiler rules (one allow-listed exception inapps/mobile/app/_layout.tsx).- Module boundaries —
web ↛ mobile,backend ↛ web|mobile,shared ↛ everything.
What pnpm check runs
typecheck (per package)
+ lint (eslint)
+ format (oxfmt + prettier --check)
+ workspace consistency (sherif)
+ module boundaries (turbo boundaries)
+ i18n key extraction dry-runThe pre-push hook runs the same set. CI runs check + tests. A failing check blocks the push.
How to add a backend feature
convex/features/<entity>/
model.ts // pure data shapes + derivation
queries.ts // wrapped userQuery exports
mutations.ts // wrapped userMutation exports
internal.ts // internalMutation / internalAction (cron + workflow targets)
actions.ts // (optional) for external-API calls or long workSkip files without callers. The pre-push gate flags unused exports.
The discover/ folder breaks this pattern — it's a single-file rails composer by design. Don't refactor it unless adding a tenth rail justifies splitting.
How to add a frontend data hook
Mobile data hooks live in one file at apps/mobile/lib/data/queries.ts. Domain types come from @app/data-contract. Mobile-specific wrappers (paginated result shapes, sort metadata) stay inline.
Hook return shape follows Convex: T | undefined while loading, T when loaded, null for not-found / forbidden. Not { data, isLoading }.
List queries default to usePaginatedQuery. Opt out via a comment in DECLINED.md only when the result set is bounded ≤ 50 rows and growth is impossible.
How to ship a release
# Mobile, TestFlight
pnpm build:staging --platform ios
pnpm submit:staging:ios
# QA on TestFlight, then:
pnpm build:prod --platform ios
pnpm submit:ios
# Web (auto)
git push # → Vercel auto-deploy to wearenxt.com
# Backend (manual)
pnpm --filter @app/backend deployIf a cron change is in the deploy: verify it appears in the Convex dashboard's Cron Jobs view after deploy.
How to debug a production issue
- Check Sentry — if DSN is set. (Not set today; turn on.)
- Check Convex function logs in the dashboard. Filter by function name; the wrapped factories log structured errors.
- Check PostHog session replays if the issue is UX-shaped.
- Reproduce locally against the dev Convex deployment. Most production issues reproduce with the same input.
packages/backend/convex/lib/logger.tsis the structured logger; production logs are searchable by tag.
What to leave alone
Some pieces look weird but are weird on purpose. Touch them only after reading the in-file comments.
collegestable =v.any(). See Data.discover.railssingle query. Single-roundtrip is intentional. Split only after measuring p99 regression.- No Cache Components on web.
cacheComponents: falseinapps/web/next.config.mjs. Opting in requires<Suspense>at every data boundary. legacy/v1-archivebranch. Reference-only. Do not merge from it; do not rebase onto it. It exists so the V1 history is one git-checkout away.- The
keys/directory pattern. Gitignored on purpose. Restore from Bitwarden.
What to enforce going forward
- Conventional commits. Body lines ≤ 100 chars. No AI attribution in messages or PR bodies.
- No
git add -Aorgit add .when multiple agents/engineers might be working. List exact paths. - No remote git actions without explicit approval — no
push, noforce-push, no PR creation as a side effect of work. (This is a long-standing operator preference; carry it forward.) - Every user action ships with its PostHog
AnalyticsEvent.*event in the same PR. The event is defined alongside the feature in@app/core/analytics. - Every mutation has a frontend caller or test. Dead mutations are bugs waiting to be discovered.
- Every workflow
define()has astart()somewhere. Lint flags orphans.
What "done" means
pnpm checkis clean.- Relevant tests pass.
- Every mutation has a frontend caller or test.
- Every workflow
define()has astart()somewhere. - Every user action ships with its PostHog event.
- Every user-uploaded image resolves through
useFileUrl()/useBatchFileUrls(). - Every user-scoped table has an ownership index.
- No placeholder TODOs or mock data left behind.
- No tombstone comments (no "removed:", "previously imported", "(was: ...)" markers in source).
Technical detail
Why so many lint rules
The codebase is built to be safe under multi-agent / multi-engineer work. Most of the lint rules exist because we found a footgun once and decided not to find it twice. The rules are documented inline at their definition sites in eslint.config.mjs and packages/eslint-config/src/custom/.
Why Ruler
pnpm ruler:apply regenerates CLAUDE.md, AGENTS.md, GEMINI.md from .ruler/*.md. Three AI-tool entry points, one source of truth. If you don't use AI tooling, the files are still useful as a written engineering guide.
Why @app/ workspace prefix
All internal packages are namespaced @app/*. This makes "is this our code or a third-party dep?" answerable by reading the import alone. New packages should follow the pattern.