NXT

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

  1. Clone, install, run. See Local setup. If pnpm check is clean and pnpm ios boots, the environment is correct.
  2. Read CLAUDE.md + AGENTS.md at repo root. Single source of truth for engineering conventions. Both files are generated from .ruler/*.md and regenerated via pnpm ruler:apply.
  3. Read packages/backend/CLAUDE.md for Convex-specific guidance and the link to Convex's AI tooling rules.
  4. 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.
  5. Read docs/v1-to-v2-summary.md for the client-facing version of the architectural change. Useful for explaining the matching engine to non-engineers.
  6. 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:

  1. Add errors.<key> to packages/i18n/locales/en.json.
  2. Add a factory function in convex/lib/errors-<feature>.ts.
  3. 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-i18ngetTranslations() / useTranslations() must take no argument.
  • react-compiler/react-compiler — React Compiler rules (one allow-listed exception in apps/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-run

The 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 work

Skip 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 deploy

If 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

  1. Check Sentry — if DSN is set. (Not set today; turn on.)
  2. Check Convex function logs in the dashboard. Filter by function name; the wrapped factories log structured errors.
  3. Check PostHog session replays if the issue is UX-shaped.
  4. Reproduce locally against the dev Convex deployment. Most production issues reproduce with the same input.
  5. packages/backend/convex/lib/logger.ts is 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.

  • colleges table = v.any(). See Data.
  • discover.rails single query. Single-roundtrip is intentional. Split only after measuring p99 regression.
  • No Cache Components on web. cacheComponents: false in apps/web/next.config.mjs. Opting in requires <Suspense> at every data boundary.
  • legacy/v1-archive branch. 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 -A or git add . when multiple agents/engineers might be working. List exact paths.
  • No remote git actions without explicit approval — no push, no force-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 a start() somewhere. Lint flags orphans.

What "done" means

  • pnpm check is clean.
  • Relevant tests pass.
  • Every mutation has a frontend caller or test.
  • Every workflow define() has a start() 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.

On this page