The Perfect Tech Stack in 2025: What I'd Choose Starting a Project Today
It's 2 AM. I've got three tabs open with documentation for frameworks that didn't exist six months ago and already have a successor. There's a 200-reply thread on X where people are going at each other over Bun vs Node. And me — cold mate sitting next to the keyboard — I made a decision: I'm getting off the hype carousel. Here's what I'd actually pick today if I had to start a project from scratch.
This isn't a tutorial. It's an opinion. A strong one, backed by real reasoning, and held up by my own mistakes.
Why Picking the Right Tech Stack in 2025 Actually Matters
Choosing a tech stack in 2025 isn't like picking sneakers. A bad stack choice follows you for years. I know this firsthand: in 2021 I started a project with Create React App because "it's what I knew," and by 2023 I was migrating to Vite with the same energy you have when you move apartments after a breakup — painful, inevitable, and with stuff that just ends up in the trash.
Today's ecosystem has a subtle trap: there are too many good options. And that paralyzes you. So what I'm doing here is simple — I'll tell you what I'd choose, why, and what I rejected with concrete arguments.
The Stack: The Decision
I'll go straight to it:
- Frontend: Next.js 15 with App Router
- Language: TypeScript everywhere
- Styles: Tailwind CSS v4
- Database: PostgreSQL
- ORM: Drizzle ORM
- Auth: Auth.js (NextAuth v5)
- Deploy: Vercel for frontend, Railway or Fly.io for backend/DB
- Containerization: Docker for local development
- Testing: Vitest + Playwright
That's it. No microservices from day one, no Kubernetes until you genuinely need it, no event sourcing for an app with twelve users.
Next.js 15: The Center of Everything
Next.js is the framework that's made me say "this is brilliant" and "I want to throw my laptop" in the same afternoon more times than I can count. But after working seriously with it — with the App Router since it hit stable — I've landed on this: nothing else comes close for full-stack projects in 2025.
The App Router changed everything. The mental model of Server Components vs Client Components feels like an unnecessary abstraction at first, but once it clicks, it's like learning to ride a bike — you can't believe you ever thought about it any other way.
// app/products/[id]/page.tsx
// This component runs on the server. Zero JS sent to the client.
import { db } from '@/lib/db'
import { products } from '@/lib/schema'
import { eq } from 'drizzle-orm'
interface Props {
params: { id: string }
}
export default async function ProductPage({ params }: Props) {
const product = await db
.select()
.from(products)
.where(eq(products.id, parseInt(params.id)))
.limit(1)
if (!product.length) return <div>Not found</div>
return (
<article>
<h1>{product[0].name}</h1>
<p>{product[0].description}</p>
</article>
)
}
That's a direct database query from a React component. No API route, no fetch, no unnecessary loading states. The HTML arrives at the browser already rendered. In 2020, this required a separate backend, a REST endpoint, loading state management... It was insane.
TypeScript: Not Optional in 2025
In 2022 I was still arguing with people about whether TypeScript was worth it. In 2025, that conversation feels archaeological to me. TypeScript isn't "more work" — it's catching bugs before your clients do.
The moment I was fully converted was on an e-commerce project where we had a function that received an order object. Without types, nobody knew what fields existed. Everyone was digging through the database or old code to figure out what was on that object. With TypeScript:
interface Order {
id: string
userId: string
items: Array<{
productId: string
quantity: number
unitPrice: number
}>
status: 'pending' | 'paid' | 'shipped' | 'cancelled'
createdAt: Date
}
function calculateTotal(order: Order): number {
return order.items.reduce(
(acc, item) => acc + item.quantity * item.unitPrice,
0
)
}
Now the IDE tells you exactly what you can do with that object. If someone adds a new field to the interface, TypeScript yells at you everywhere it matters. That's real productivity.
Drizzle ORM: The ORM That Doesn't Hide SQL From You
This is where I'm going to make some enemies: Prisma is overrated.
Prisma is fantastic for getting started, the documentation is excellent, and the initial developer experience is genuinely great. But once you start needing complex queries, you find yourself fighting the ORM instead of working with it. The Prisma client is an abstraction layer that sometimes does black magic with the generated SQL, and when something breaks, debugging it is a nightmare.
Drizzle is different. Drizzle is "SQL but with types." The API is designed so you know exactly what query is being executed:
// lib/schema.ts
import { pgTable, serial, text, timestamp, integer } from 'drizzle-orm/pg-core'
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: text('email').notNull().unique(),
name: text('name').notNull(),
createdAt: timestamp('created_at').defaultNow()
})
export const orders = pgTable('orders', {
id: serial('id').primaryKey(),
userId: integer('user_id').references(() => users.id),
total: integer('total').notNull(),
status: text('status').notNull().default('pending')
})
// Usage in any Server Component or Server Action
const usersWithOrders = await db
.select({
user: users,
orderCount: count(orders.id)
})
.from(users)
.leftJoin(orders, eq(orders.userId, users.id))
.groupBy(users.id)
.where(gt(count(orders.id), 0))
You know what SQL is running. You get full autocomplete. If the schema changes, TypeScript breaks exactly where there are inconsistencies. It's the perfect balance between control and ergonomics.
PostgreSQL: Boring and Perfect
No, I'm not using MongoDB. I've used it. I've migrated from MongoDB to PostgreSQL on a project that grew. It was awful.
PostgreSQL in 2025 does everything: native JSON when you need flexibility, full-text search, extensions like pgvector for AI embeddings, real ACID transactions. It's the database that scales from your laptop to millions of users without you having to relearn anything.
The only real argument for MongoDB today is "my team knows it better" — and that argument has an expiration date.
What I Rejected and Why
Remix: I genuinely love Remix's mental model. Loaders and actions are elegant. But the ecosystem is smaller, the integration with the broader React world is more friction-heavy, and Vercel is investing in Next.js in a way that makes it very hard to compete on feature velocity. If Shopify keeps betting hard on Remix, I'll re-evaluate.
SvelteKit: Svelte is a joy to write. Seriously. But the job market and the sheer volume of libraries available for React aren't even close. If I'm solo on a project, maybe. With a team, I can't ask everyone to learn Svelte.
tRPC: I used it, I liked it, but with Next.js App Router and Server Actions, the overhead of setting up tRPC is harder and harder to justify. Server Actions with TypeScript give you end-to-end type safety without the extra infrastructure:
// app/actions/orders.ts
'use server'
import { db } from '@/lib/db'
import { orders } from '@/lib/schema'
export async function createOrder(data: {
userId: number
items: Array<{ productId: number; quantity: number }>
}) {
// Validation, business logic, write to DB
const [newOrder] = await db
.insert(orders)
.values({ userId: data.userId, status: 'pending', total: 0 })
.returning()
return newOrder
}
That gets called from a Client Component with await createOrder(data) and TypeScript guarantees the types are correct end-to-end. tRPC solved.
Bun as a production runtime: Bun is insanely fast. I use it to run tests and local scripts and it's a pleasure. But for production in 2025, I'm still staying with Node. The ecosystem, the proven stability, and the sheer volume of troubleshooting articles available when something goes sideways at 3 AM are still solid arguments.
The Dev Environment: Docker Yes, But With Purpose
Docker for local development is non-negotiable. Don't install PostgreSQL directly on your machine. Don't ask your team to run Redis natively. A simple docker-compose.yml handles everything:
# docker-compose.yml
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: dev
POSTGRES_PASSWORD: dev
POSTGRES_DB: myapp
ports:
- '5432:5432'
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- '6379:6379'
volumes:
postgres_data:
docker compose up -d and your environment is ready. Anyone on the team clones the repo, runs that command, and they're in. No more "it works on my machine."
The Elephant in the Room: What About AI?
Every stack in 2025 needs an answer for generative AI. Mine is: start simple.
Vercel AI SDK with any OpenAI or Anthropic model covers 90% of use cases. pgvector in PostgreSQL for embeddings. You don't need Pinecone, you don't need a specialized vector database until you have a real scale problem that justifies the complexity.
The Conclusion Nobody Wants to Hear
The best tech stack in 2025 isn't the newest one, isn't the most performant on synthetic benchmarks, and isn't the one with the most GitHub stars this week. It's the one that lets you ship — with quality, with maintainability, with a team that can get up to speed fast.
Next.js + TypeScript + PostgreSQL + Drizzle is my answer to that question today. It might change next year. But if it does, it'll be because something fundamentally better showed up — not because someone convinced me with a pretty Twitter thread and some bar charts.
Start building. Benchmarks are for conference talks. The code that hits production is the only code that matters.
Top comments (0)