Node.js Backend Stack
Live docs: Add use context7 to prompt for up-to-date Hono, Drizzle, Zod documentation.
Quick Reference
| Topic |
Reference |
| Testing |
testing.md — Vitest patterns, mocking, API tests |
Tooling (2025)
| Tool |
Purpose |
Why |
| pnpm |
Package manager |
Fast, disk efficient |
| Vitest |
Testing |
Fast, ESM native, Jest compatible |
| Drizzle |
ORM |
Type-safe, lightweight, SQL-like |
| Hono |
Web framework |
Fast, lightweight, edge-ready |
| NestJS |
Framework |
Enterprise, DI, structured |
| Zod |
Validation |
Type inference, composable |
| ESLint 9 |
Linting |
Flat config, modern |
Project Setup
# Hono (lightweight)
pnpm create hono@latest my-api
cd my-api
pnpm add drizzle-orm postgres zod
pnpm add -D drizzle-kit vitest @types/node typescript
# NestJS (enterprise)
pnpm dlx @nestjs/cli new my-api
pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit vitest
TypeScript Config
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
ESLint 9 Flat Config
// eslint.config.js
import js from '@eslint/js';
import typescript from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
export default [
js.configs.recommended,
{
files: ['**/*.ts'],
languageOptions: {
parser: tsParser,
parserOptions: { project: './tsconfig.json' },
},
plugins: { '@typescript-eslint': typescript },
rules: {
...typescript.configs.recommended.rules,
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
},
];
Project Structure
src/
├── index.ts # Entry point
├── config.ts # Environment config
├── db/
│ ├── index.ts # Drizzle client
│ ├── schema.ts # Table definitions
│ └── migrate.ts # Migration runner
├── routes/
│ ├── index.ts
│ ├── auth.ts
│ └── users.ts
├── services/
│ └── user.ts
├── middleware/
│ ├── auth.ts
│ └── error.ts
└── types/
└── index.ts
tests/
├── setup.ts
└── users.test.ts
drizzle/
└── migrations/
Hono Patterns
Basic App
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { HTTPException } from 'hono/http-exception';
const app = new Hono();
app.use('*', logger());
app.use('*', cors());
app.get('/', (c) => c.json({ status: 'ok' }));
app.onError((err, c) => {
if (err instanceof HTTPException) {
return c.json({ error: err.message }, err.status);
}
console.error(err);
return c.json({ error: 'Internal Server Error' }, 500);
});
export default app;
Route with Validation
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
const users = new Hono();
const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
});
users.post('/', zValidator('json', createUserSchema), async (c) => {
const data = c.req.valid('json');
const user = await userService.create(data);
return c.json(user, 201);
});
users.get('/:id', async (c) => {
const id = c.req.param('id');
const user = await userService.findById(id);
if (!user) {
throw new HTTPException(404, { message: 'User not found' });
}
return c.json(user);
});
export default users;
Auth Middleware
import { createMiddleware } from 'hono/factory';
import { HTTPException } from 'hono/http-exception';
import { verify } from 'hono/jwt';
type Env = {
Variables: { userId: string };
};
export const authMiddleware = createMiddleware<Env>(async (c, next) => {
const header = c.req.header('Authorization');
if (!header?.startsWith('Bearer ')) {
throw new HTTPException(401, { message: 'Missing token' });
}
const token = header.slice(7);
try {
const payload = await verify(token, process.env.JWT_SECRET!);
c.set('userId', payload.sub as string);
await next();
} catch {
throw new HTTPException(401, { message: 'Invalid token' });
}
});
Drizzle ORM
Schema Definition
// src/db/schema.ts
import { pgTable, serial, varchar, timestamp, integer } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
name: varchar('name', { length: 100 }).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
userId: integer('user_id').references(() => users.id).notNull(),
title: varchar('title', { length: 255 }).notNull(),
body: varchar('body', { length: 10000 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// Types
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
Database Client
// src/db/index.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
const client = postgres(process.env.DATABASE_URL!);
export const db = drizzle(client, { schema });
Queries
import { db } from '@/db';
import { users, posts } from '@/db/schema';
import { eq, desc } from 'drizzle-orm';
// Insert
const [user] = await db.insert(users)
.values({ email: 'test@example.com', name: 'Test' })
.returning();
// Select
const user = await db.query.users.findFirst({
where: eq(users.email, 'test@example.com'),
});
// Select with relations
const userWithPosts = await db.query.users.findFirst({
where: eq(users.id, userId),
with: { posts: true },
});
// Update
await db.update(users)
.set({ name: 'New Name' })
.where(eq(users.id, userId));
// Delete
await db.delete(users).where(eq(users.id, userId));
// Pagination
const usersList = await db.select()
.from(users)
.orderBy(desc(users.createdAt))
.limit(20)
.offset(0);
Migrations
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
# Generate migration
pnpm drizzle-kit generate
# Apply migrations
pnpm drizzle-kit migrate
# Studio (GUI)
pnpm drizzle-kit studio
Config with Zod
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
});
export const env = envSchema.parse(process.env);
Anti-patterns
| Don't |
Do Instead |
npm |
pnpm |
| Jest |
Vitest |
| Prisma (heavy) |
Drizzle (lightweight) |
| Express (old) |
Hono (modern, fast) |
.eslintrc |
eslint.config.js (flat config) |
| CommonJS |
ESM ("type": "module") |
any types |
Proper TypeScript types |
| Callback patterns |
async/await |