This is Part 2 of the tutorial series. If you haven’t read Part 1: Introduction, I recommend starting there.
In this part, I want to be explicit about the tech stack behind the chatbot project. Not just what I’m choosing, but why those choices make sense for this specific product.
Architecture Overview
I’m going with a classic client-server architecture with communication powered by tRPC. Simple, battle-tested, and perfectly suited for our needs.
┌─────────────┐ tRPC ┌─────────────┐
│ React │ ◄────────────► │ NestJS │
│ (Vite) │ │ Server │
└─────────────┘ └──────┬──────┘
│
▼
┌─────────────┐
│ PostgreSQL │
│ (Prisma) │
└─────────────┘
The Client: React + Vite + TanStack Router
On the frontend, I’m keeping things straightforward: React as the UI layer, Vite for fast dev builds and simple configuration, TanStack Router for type-safe routing, and Tailwind CSS for styling. For UI components, I’m using shadcn/ui, which is pretty much the expected choice for React + Tailwind projects these days. It’s built on top of Radix UI primitives, so you get solid accessibility out of the box (focus management, keyboard navigation, ARIA attributes), while the styling stays in Tailwind. The nice thing about shadcn is that you copy the component source into your project, so you own the code and can customize freely without fighting a library’s abstractions.
This should give us a modern client without dragging backend concerns into the frontend framework.
Why Not Next.js?
If I let an LLM pick the stack, it would almost certainly recommend Next.js. Which kinda makes sense since it has a huge ecosystem, great docs, and for many teams it’s the default choice for React apps in 2025 (probably still true in 2026, let’s see).
But I don’t think this project really needs it.
This chatbot doesn’t require server-side rendering, React Server Components, edge functions, or server actions. Specially, after the known vulnerabilities with React Server Components (CVE-2025-55182, CVE-2025-55184, and the other ones discovered right afterwards) I don’t feel any enthusiastic about using them.
What I prefer for this project is a clean separation of concerns between the client and the server, where the server handles authentication, database connection pooling, background jobs (cron tasks), and http streaming for real-time chat and the client handles the UI and the user interactions smoothly and performant thanks to the use of the React Compiler and code splitting.
The Server: NestJS
NestJS is a Node.js framework that brings structure to backend development. If you’ve built backends in Java, especially with Spring Boot, a lot of it will feel familiar: decorators that read like annotations, dependency injection, and a module-first structure that keeps things organized as the codebase grows.
I used to be an Android Java dev back in the day, and I still prefer codebases with clear boundaries and using design patterns like MVC, MVVM, MVP, etc. NestJS is TypeScript-first, scales nicely as you add features, and it gives you solid primitives for cross-cutting concerns (guards, interceptors, middleware) with ease.
@Controller('chat')
export class ChatController {
@Post()
@UseGuards(AuthGuard)
async createMessage(@Body() dto: CreateMessageDto) {
// Clean, declarative, type-safe
}
}
The ORM: Prisma
For database access, I’m using Prisma over the newer Drizzle ORM or other options like the even more mature Sequelize or TypeORM.
This was a tough call to be honest. There are 2 clear winners here for modern ORMs: Drizzle and Prisma. Drizzle has been getting a lot of hype for its performance and SQL-like syntax, and I do like its approach. But for this project I’m choosing the more battle-tested option of the two: Prisma has more real-world edge cases documented, the day-to-day DX is hard to beat, and the schema + migrations workflow is consistently reliable.
I have not used TypeORM before so I really can’t really comment on it, and as for Sequelize, I have indeed used it and believe is too verbose and complex for this project, requires manual migration files, DX-wise not ideal either because it is not typescript-first like Prisma so not a good fit under my opinion.
The Database: PostgreSQL
I picked PostgreSQL mostly for a simple reason: it just feels like it is the default choice for a relational database these days. If you ask ten developers what to use for a new product, a lot of them will say Postgres without thinking too hard, and actually there’s some value in following that.
Back in the day, MySQL used to be the obvious default, but it feels less community-driven since the Oracle acquisition. And while open source alternatives like MariaDB are solid, I’m not convinced they get the same level of long-term ecosystem support and maintenance as Postgres.
Technically, Postgres also fits this project really well as it behaves great under concurrency, it’s strict about data integrity, and it has a deep feature set you end up appreciating as the product grows. JSONB is genuinely useful when you’re building AI products (metadata, model configs, tool call traces, provider payloads). And importantly for what I want to build next, Postgres has strong extension support for vectors, so we can store and query embeddings (for example with pgvector) for when we add RAG features later on.
Authentication: Better Auth
I remember when I started coding that I had to set up authentication from scratch (or so I thought): create a sessions table, handle password hashing with bcrypt (plus some salt and maybe some pepper cause why not), manage JWTs, refresh tokens, email validation, OTP, forgot password flows… and then repeat that custom system for every new project or copy-paste it from a previous one with minor modifications.
Then, in more recent years, after the surge of Next.js, a library called NextAuth (now Auth.js) became the de facto standard for authentication in the Next.js ecosystem. But since we’re not using Next.js, we need something more flexible.
Enter Better Auth.
Better Auth has been growing rapidly in popularity, and for good reason. It’s framework-agnostic, type-safe, and designed with modern patterns in mind. It’s also maintained by the same guys currently behind NextAuth itself, which gives me confidence in its long-term viability.
For our stack, Better Auth fits nicely:
- Server-side (NestJS): We wire it up with a direct connection to our Postgres database via Prisma. It handles session management, password hashing, and token generation out of the box.
- Client-side (React): The React integration works seamlessly, and it uses HTTP-only cookies for session storage by default, which is ideal for web apps as it prevents XSS attacks and, combined with other security measures like SameSite attributes and origin validation, provides comprehensive protection against CSRF attacks.
So no more reinventing the wheel for every project. Authentication is one of those things where using a battle-tested library is almost always the right call. Other options worth mentioning include Keycloak, which is a bit overkill for this project, and Firebase Auth, which suffers from vendor lock-in, depends on Google not killing firebase (we all know how that goes), and is not as customizable as Better Auth.
The Communication Layer: tRPC
tRPC stands for TypeScript Remote Procedure Call. Instead of defining REST endpoints with URLs and HTTP methods, you define procedures: functions that can be called from the client as if they were local. The big win is end-to-end type safety across the boundary.
Server (NestJS):
export const appRouter = router({
chat: router({
sendMessage: publicProcedure
.input(z.object({ content: z.string() }))
.mutation(async ({ input }) => {
// Save to DB, call AI, etc.
return { id: '123', content: input.content };
}),
}),
});
export type AppRouter = typeof appRouter;
Client (React):
// Full autocompletion and type safety!
const mutation = trpc.chat.sendMessage.useMutation();
mutation.mutate({ content: 'Hello AI!' });
// TypeScript knows the response shape automatically
Why Not REST + OpenAPI?
In past projects, I’ve built REST APIs with Swagger decorators and used code generators like Orval to create typed clients and it works, but it comes with a few caveats: Swagger docs can drift out of sync with real types really easily, you need to add an extra generation step to your workflow, and you can end up with runtime mismatches when the backend changes and the client hasn’t been regenerated yet.
Now, with tRPC in a monorepo, types flow smoothly from server to client. Change a procedure’s return type and your frontend will complain immediately, which keeps types in sync during refactors and speeds up iteration. It’s also a nice fit when you’re iterating quickly with AI-assisted coding, because type differences show up instantly so you (or AI) can fix it before the code even hits production.
A Note on Performance
One thing to be aware of is tRPC’s type inference can slow down TypeScript in large projects. And realistically, a chatbot backend can easily grow into dozens of procedures as you add chat, users, billing, admin tools, analytics, and integrations.
But, the good news is that if TypeScript starts to feel sluggish, there are a few practical ways to keep it under control: keep routers modular (split by domain instead of one giant router), avoid importing inference-heavy types everywhere, and consider exporting thinner types (for example RouterInputs/RouterOutputs) or consuming prebuilt .d.ts types from the server package so the client isn’t constantly re-inferring everything.
Linting & Formatting: Biome
For linting and formatting across the entire monorepo, I’m going with Biome instead of the more popular and battle-tested ESLint + Prettier combo.
Why Not ESLint + Prettier?
Honestly? Configuring ESLint for each project is annoying. I can’t think of a single time I’ve actually enjoyed doing it. Even when AI generates the config, it often ends up bloated with plugins, extends, overrides, and rules that I don’t fully understand or need.
Biome takes a different approach: one tool for both linting and formatting, with sensible defaults and minimal configuration. A single biome.json at the root of the monorepo handles everything.
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"organizeImports": { "enabled": true },
"linter": { "enabled": true },
"formatter": { "enabled": true, "indentStyle": "space" }
}
That’s it. No .eslintrc.js, no .prettierrc, no eslint-config-* packages, no plugin compatibility headaches.
The other thing I love about Biome is how blazing fast it is. It’s written in Rust, and you can feel the difference immediately. Formatting an entire monorepo happens in milliseconds, not seconds. When you’re iterating quickly, with lots of code being generated by AI, that speed compounds.
Summary
| Layer | Choice | Why |
|---|---|---|
| Frontend | React + Vite | Fast builds, simple config |
| Routing | TanStack Router | Type-safe, modern |
| Styling | Tailwind CSS | Utility-first, rapid development |
| UI Components | shadcn/ui (Radix) | Own the code, accessible, customizable |
| Backend | NestJS | Structure, TypeScript, scalability |
| ORM | Prisma | Mature, reliable, great DX |
| API Layer | tRPC | End-to-end type safety |
| Database | PostgreSQL | Strong default, reliable ecosystem, great feature set |
| Auth | Better Auth | Framework-agnostic, type-safe, secure defaults |
| Linting & Formatting | Biome | Simple config, blazing fast, all-in-one |
In Part 3, we’ll set up the monorepo structure and get our development environment running. See you there!
📁 Repository State: The current state of the codebase described in this article is available in the
feat/monorepo-setupbranch on GitHub.