Skip to Content
QA Docs & Abangku 0.1.0 is released. Read more →
Product EngineerTechnology Selection & Current Stack

Technology Selection & Current Stack

2.1 Selection criteria

This subsection is the part of the document that outlives the current stack. When a new tool or library is proposed (for a greenfield service or as a replacement for something in 2.2), evaluate it against these criteria, in this order. They are reusable on purpose: the stack in 2.2 was chosen against this same list, and it should be re-measured against it, not grandfathered in.

  1. Type safety. Does it preserve or improve end-to-end type safety between the layers it touches (client ↔ server, code ↔ schema, code ↔ database)? A tool that forces a boundary back to any, untyped JSON, or manual type declarations is a regression even if it’s otherwise attractive.
  2. Ecosystem maturity. Is it actively maintained, does it have a realistic Bus factor (not one maintainer), and does it have enough production usage elsewhere that we’re not the ones finding its edge-case bugs? Prefer boring and maintained over novel and exciting.
  3. Team familiarity. Does the team already know it, or is the learning curve justified by the problem it solves? Introducing an unfamiliar tool to solve a minor problem is a net loss even if the tool is good.
  4. Hiring pool. Can we reasonably hire for this? A niche or proprietary technology narrows the hiring pool and raises onboarding cost for every future hire, not just the first one.
  5. Long-term maintenance cost. Who patches it, upgrades it, and answers questions about it in two years? Weigh this against the short-term velocity gain: a tool that’s fast to adopt but expensive to keep current is a loan, not a gift.

Rule: a new tool must clear all five criteria, not just the one that motivated the proposal (e.g. “it’s fast” is not sufficient on its own if it fails on ecosystem maturity or hiring pool). Document how it clears each one in the proposal (see 2.5).

2.2 Current approved stack

This is the current stack as of this document’s last update (see the change log for revision history). Unlike the rest of this document, this subsection is allowed to state tools as fact. If you are reading this in a future stack migration, trust 2.1 over 2.2: 2.2 is a snapshot, not a principle.

Language & runtime

  • TypeScript/Node.js (primary): end-to-end type safety across frontend and backend, largest hiring pool, one language for both teams.
  • Go (allowed, performance-sensitive isolated services): used only where a service’s workload genuinely needs it; see 2.3.
  • Python (allowed, data/scripting jobs): used for data-processing and scripting workloads where its ecosystem (data/ML libraries) is the deciding factor; see 2.3.
  • Rust (requires explicit justification): not approved for general use; requires a case-by-case justification under 2.3 before use.
  • npm (default) or pnpm (optional): pick one package manager per repository. With npm, a committed package-lock.json is the source of truth for resolved versions; with pnpm, a committed pnpm-lock.yaml is. Do not mix lockfiles (package-lock.json, pnpm-lock.yaml, yarn.lock) in the same repository.

Frontend framework

  • Next.js: unifies routing, rendering strategy, and API layer in one framework the whole team already knows.

Styling

  • Tailwind CSS: utility-first styling with design tokens, avoids hand-rolled CSS drift across two teams.
  • shadcn/ui: accessible, unstyled-by-default primitives that are copied into the codebase rather than installed as a dependency (see 5.1), so they can be customized without fighting an external API surface.
  • shadcn/ui violet theme: the default color theme for shadcn/ui primitives across both product surfaces; see 5.1 for the rule on when a project may deviate from it.
  • Plus Jakarta Sans (UI/body text) and Source Serif 4 (display headings): the two approved typefaces; see 5.2 for how they’re applied.

Internal API layer

  • tRPC: default for any API only our own frontend(s) consume; keeps the client/server boundary fully typed with no schema duplication (see 7.2).
  • Fastify (standalone backend services only): used when a backend workload needs to be deployed and iterated on independently of the Next.js app, so a backend-only change doesn’t require touching or redeploying the frontend. Not a replacement for tRPC/Next.js API routes for UI-coupled endpoints; see 7.2 for when a standalone service is warranted at all.

External/partner-facing API

  • REST: used the moment an external party consumes an API: partners, public docs, webhooks.
  • GraphQL (Apollo): used only when clients have meaningfully different data-shape needs from the same backend, or when aggregating multiple internal services into one graph (see 7.2). Not a default.

Database

  • MongoDB: document model fits our current domain shapes and iteration speed; see 6.6 for when a relational store would be the better choice instead.
  • Mongoose: schema enforcement and validation on top of MongoDB’s otherwise schemaless documents.
  • PostgreSQL (relational store, where 6.6’s criteria call for one): used instead of MongoDB when a workload’s domain shape is genuinely relational; see 6.6 for the escalation criteria. Not a default; MongoDB remains the default store.
  • Kysely: typed SQL query builder for PostgreSQL, chosen over an ORM to keep query shape and generated SQL explicit while still preserving end-to-end type safety from schema to query result (see 2.1).

Authentication

  • Better Auth: TypeScript-first auth framework for application-level sign-in and session management, keeping auth types end to end between client and server.
  • Azure Entra ID (MSAL): primary enterprise identity provider, matches our Azure-hosted infrastructure.
  • Google OAuth 2.0: supports external/non-enterprise sign-in where required.
  • jose: JWT signing/verification for our own issued tokens, chosen for being a maintained, spec-compliant, dependency-light library.

Authorization

  • CASL: isomorphic permission rules usable on both client (UI gating) and server (enforcement), avoiding duplicated authorization logic.

Search infra

  • Azure AI Search: used only once DB-native search is insufficient; see 9.4 for the escalation criteria. Chosen over alternatives for native Azure integration.

File/object storage

  • Azure Storage Blob: direct-to-storage upload target, matches our Azure hosting; see 9.5.
  • react-dropzone: standard client-side upload UI, pairs with the presigned-URL upload pattern.

Background jobs / queues / scheduled tasks

  • Azure Service Bus: durable message queue for async and retry-capable background work; see 10.4.
  • Azure Container Apps Jobs: scheduled and event-driven job execution on the same platform as our hosted services.

Real-time / WebSocket

  • Azure Web PubSub: managed WebSocket infrastructure, avoids self-hosting connection state for real-time features.

Hosting

  • Azure Static Web Apps: frontend hosting with integrated CDN.
  • Azure Container Apps: backend service hosting with autoscaling, without full Kubernetes operational overhead. The default for an actual service.
  • Azure Function Apps: for lightweight, event-driven triggers (e.g. blob-triggered processing, timer triggers, small webhooks) where a full container is not justified; see 10.4 for where this fits against the queue/job criteria. Not a replacement for Container Apps as the default backend hosting choice. This stack is suggested and proven work by Petrus Dimas E of AI Engineer Team.

Secrets management

  • Azure Key Vault: centralized secrets store; see 11.4. Never in code, never logged.

Validation

  • Zod: single source of truth for request/response and domain schemas, shared between client and server; see 9.3.

Forms

  • React Hook Form + @hookform/resolvers: the one standard form pattern, resolved against Zod schemas; see 5.10. No ad hoc controlled inputs.

State management

  • Zustand: minimal client-only state store; see 9.1 for the boundary between this and server state.
  • nuqs: type-safe URL search-param state, see 9.1 for the boundary this covers and 5.22 for the related routing conventions.

Server state / data fetching

  • TanStack Query: server-cache state and data fetching, paired with tRPC.
  • TanStack Table: standard table/data-grid primitive to avoid bespoke table logic per feature.

Caching (in-memory)

  • lru-cache: process-local caching for hot, cheap-to-recompute data that doesn’t need a distributed cache.

Feature flagging

  • In-house DB-backed toggle: a flags table plus application code, no third-party platform. This is tier 2 of the tiers defined in 8.9, the current need doesn’t yet justify a full platform (LaunchDarkly, GrowthBook); revisit that choice per 8.8’s criteria if that changes.

Testing

  • Vitest: unit and integration test runner, fast and TypeScript-native.
  • Playwright: end-to-end browser testing.
  • Testing Library: component testing at the DOM/behavior level rather than implementation detail; see 13.

Load and performance testing

  • Locust: load testing for simulating concurrent user traffic against an endpoint or flow, used to validate the query and performance rules in 12 hold under real load, not just correctness in isolation.

Dependency security

  • npm audit (or pnpm audit in repositories using pnpm): dependency vulnerability scanning, run in CI on every PR; see 6.13.

Icons

  • lucide-react: matches shadcn/ui’s default icon set; see 5.12. Do not mix in a second icon library.

Animation

  • motion: used past the complexity threshold where Tailwind transitions alone aren’t sufficient; see 5.13.

Observability

  • Sentry: error tracking.
  • Pino: structured application logging.
  • OpenTelemetry: vendor-neutral tracing/metrics instrumentation.
  • Azure Monitor, Grafana, Prometheus: metrics collection and dashboards.

Analytics

  • PostHog: product analytics.

Monorepo tooling

  • Turborepo: task orchestration and caching across the monorepo; see 3.1.

Date/time

  • date-fns: the one date/time library; do not mix with alternatives (e.g. Moment, Luxon, Day.js) in the same codebase.

PDF generation

  • jsPDF, pdfjs-dist: client-side PDF generation and rendering.

CSV/spreadsheet

  • xlsx, Papaparse: spreadsheet and CSV import/export.

Large-file analytical query

  • DuckDB: in-process analytical SQL engine, used to query large Excel/spreadsheet files without loading and processing them row by row in application code, or standing up a full database for what is fundamentally a one-off or periodic analytical query. This stack is suggested and proven work by Luky Prasetya of Architect Cloud Engineer Team.
  • Parquet: columnar storage format used alongside DuckDB when a large dataset is queried repeatedly, converting once to Parquet avoids re-parsing the original spreadsheet format on every query.

Rich text / Markdown

  • react-markdown, remark-gfm, rehype-raw: Markdown rendering where rich text display is required.

Drag-and-drop

  • dnd-kit: accessible drag-and-drop interactions.

Charting

  • Recharts v3: the standard charting library. Do not introduce a second charting library for a one-off visualization need.

Component documentation

  • Storybook, Chromatic: component development in isolation and visual regression review for shared UI components.

Client-side export

  • html-to-image: client-side export of UI to image, e.g. for chart/report export.

2.3 Polyglot policy

The default is TypeScript/Node.js end to end. A non-JS/TS service is acceptable only when it meets at least one of these criteria, not because a team or engineer simply prefers another language:

  • Performance ceiling. The workload has a measured, not assumed, performance requirement that Node.js cannot meet even after optimization (e.g. CPU-bound processing at a latency or throughput floor Node can’t hit). This is the justification for Go being allowed today.
  • Ecosystem fit. The problem domain has a dominant, substantially better ecosystem in another language (e.g. data science and ML tooling in Python) such that reimplementing it in TS/Node would mean reinventing mature libraries. This is the justification for Python being allowed today for data/scripting jobs.
  • Isolation. The service is a genuinely isolated boundary: it doesn’t share types, models, or a deploy unit with the rest of the system, so the type-safety cost from 2.1 is not paid across a shared boundary, only within the service itself.

Rust requires an explicit, written justification against these same three criteria before use; it is not pre-approved the way Go and Python are.

Honest current state: in practice, essentially all product code today is TypeScript/Node.js. The polyglot allowances exist for cases that meet the criteria above, not because we currently operate a meaningfully polyglot system.

Rule: “the language is nicer to write in” or “I prefer it” is never sufficient justification on its own. A polyglot proposal must name which of the three criteria it satisfies and provide evidence (a benchmark, a concrete ecosystem gap, or a clear isolation boundary).

2.4 Deprecated tools

A tool is deprecated when it is replaced by something in 2.2, or when it fails the criteria in 2.1 badly enough that new usage would create future maintenance debt. Deprecation does not mean instant removal:

  • No new usage. Deprecated tools must not be used in new code, even if existing code still depends on them.
  • Migration is opportunistic, not urgent, unless stated otherwise. Replace deprecated tools when you’re already touching that code, not as a dedicated rewrite project, unless the deprecation notice says otherwise (e.g. a security or maintenance-risk driven deprecation).
  • Every deprecation is logged. Record the tool, the replacement, the reason, and the date in the change log, the same place stack additions are logged.

There are no deprecated tools recorded as of this document’s current revision. When one occurs, add it here as a dated entry rather than silently removing it from 2.2.

2.5 Evaluating a new tool

To propose adding a tool to the approved stack:

  1. Identify the gap. State the concrete problem the current stack doesn’t solve well. “This is popular” or “I want to try it” is not a gap.
  2. Score it against 2.1. Address all five criteria explicitly: type safety, ecosystem maturity, team familiarity, hiring pool, long-term maintenance cost. A proposal that skips a criterion is incomplete.
  3. Name what it replaces, if anything. If it overlaps with an existing tool in 2.2, state whether the existing tool becomes deprecated (2.4) or the two are meant to coexist for different cases. Coexistence without a stated reason is not allowed, since it just fragments the stack.
  4. Get lead sign-off. Follow the same process as any other change to this document (see 1.3): open a PR against this document, and get sign-off from the engineering lead before the tool is considered approved.
  5. Update 2.2 and the change log together. A tool is not “approved” by verbal agreement or a Teams thread; it’s approved when it’s in this document and logged.

Rule: do not add a dependency to the codebase in advance of it being approved here, even for a proof of concept intended to make the case for it; prototype it in a throwaway branch or repo instead, so an unapproved dependency never lands on a shared branch.