Dependencies That Actually Matter
Every Express + Claude tutorial uses JavaScript. Don't. TypeScript will save your ass when you're debugging why response.content[0].text
is undefined at 2am on a Saturday because your users decided that's the perfect time to break shit. Claude's response objects are nested like a fucking Russian doll - without types, you'll spend 3 hours in the VS Code debugger figuring out if it's content.text
, message.content
, or response.message.content[0].text.value
or some other bullshit structure that changes based on what model you're hitting.
The dependency versions matter more than you'd think. Anthropic SDK 0.29.0 fixed a streaming memory leak that took down our staging server for 2 hours on a Thursday morning while the CEO was demoing to investors. Express 4.21.0 patches CVE-2024-27982 and two other security holes that script kiddies love to exploit. Don't use anything older than these versions in production unless you enjoy explaining security breaches to your boss.
Essential Dependencies:
{
"dependencies": {
"@anthropic-ai/sdk": "^0.29.0",
"express": "^4.21.0",
"helmet": "^8.0.0",
"express-rate-limit": "^7.4.1",
"cors": "^2.8.5",
"express-validator": "^7.2.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/cors": "^2.8.17",
"typescript": "^5.6.0",
"nodemon": "^3.1.4"
}
}
Don't build your own HTTP client. The official SDK handles all the shit you'll forget: retry logic when Claude's servers hiccup, proper error parsing so you don't get cryptic 400s, and streaming that won't eat your memory. I wasted 2 weeks building a custom client that the SDK did better.
Authentication and API Key Management
API key management will destroy your budget if you screw it up. Junior dev on my team committed his key to a public repo at 11:30pm on a Friday night. Woke up Saturday morning to a $2,400 bill - some crypto asshole was using our API to generate trading signals for his shitcoin pump-and-dump scheme. GitHub's secret scanning caught it 8 hours too late, right after the automated email notifications started flooding my inbox.
Set billing alerts at $20, $50, $100. Trust me on this one. Environment variables aren't enough - someone will still console.log(process.env.ANTHROPIC_API_KEY)
and commit it because they were debugging auth issues. Use .env
files locally, AWS Secrets Manager in production, and pray your team reads the security docs.
Environment Configuration:
// config/environment.ts
export const config = {
anthropic: {
apiKey: process.env.ANTHROPIC_API_KEY,
baseURL: process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com'
},
server: {
port: process.env.PORT || 3000,
nodeEnv: process.env.NODE_ENV || 'development'
}
};
if (!config.anthropic.apiKey) {
throw new Error('ANTHROPIC_API_KEY environment variable is required');
}
// Check API key format early - Claude will just say "invalid" without this
if (!config.anthropic.apiKey.startsWith('sk-ant-')) {
throw new Error('Invalid ANTHROPIC_API_KEY format - should start with sk-ant-');
}
Client Initialization:
// services/claude.ts
import Anthropic from '@anthropic-ai/sdk';
import { config } from '../config/environment';
export const claude = new Anthropic({
apiKey: config.anthropic.apiKey,
maxRetries: 3,
timeout: 60000 // Claude takes forever on complex requests
});
// Test connection at startup so you know immediately if keys are fucked
export async function validateConnection() {
try {
await claude.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 10,
messages: [{ role: 'user', content: 'test' }]
});
console.log('✓ Claude API connection validated');
} catch (error) {
console.error('✗ Claude API connection failed:', error.message);
process.exit(1); // Kill the server if Claude isn't working
}
}
Express Application Structure
Middleware order will fuck you up every single time. CORS first, then rate limiting, then auth. Get it wrong and you'll spend 4 hours on a Tuesday afternoon debugging why POST requests work in Postman but return 401s from your React frontend, only to realize you put the auth middleware before CORS and it's rejecting preflight requests.
That 10MB JSON limit? Claude responses are massive when you ask it to analyze code or write documentation. Hit a complex request without it and you get PayloadTooLargeError: request entity too large
- learned that one at 1:30am debugging a customer's 8MB response that contained their entire codebase analysis. Bumped it to 10MB and it worked perfectly.
Core Application Setup:
// app.ts
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import { errorHandler } from './middleware/errorHandler';
import { claudeRoutes } from './routes/claude';
const app = express();
// Security middleware
app.use(helmet());
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || 'http://localhost:3000',
credentials: true
}));
// Rate limiting - Claude's limits will destroy you if you're not careful
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Conservative - new Claude accounts get 5 RPM, you'll hit it fast
message: 'Too many requests from this IP, please try again later',
standardHeaders: true, // Return rate limit info in headers
legacyHeaders: false // Disable X-RateLimit-* headers
});
app.use(limiter);
app.use(express.json({ limit: '10mb' }));
// Routes
app.use('/api/claude', claudeRoutes);
// Error handling
app.use(errorHandler);
export { app };
Message Processing and Streaming
Streaming is a fucking nightmare. The Anthropic docs make it look easy with their cute 10-line examples, but don't mention client disconnects mid-stream, malformed UTF-8 chunks that crash your JSON parser, or memory leaks when streams don't close properly. I watched a streaming bug take down production for 90 minutes because it didn't handle users closing their browser tabs - Node.js kept writing to dead connections until it exhausted file descriptors and the whole process crashed with EMFILE: too many open files
.
This code handles all the bullshit that breaks in production and ruins your weekend.
Standard Message Processing:
// services/messageProcessor.ts
import { claude } from './claude';
import { MessageCreateParams } from '@anthropic-ai/sdk/resources/messages';
export class MessageProcessor {
async processMessage(params: MessageCreateParams) {
try {
const response = await claude.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 4096,
...params
});
return {
content: response.content,
usage: response.usage,
role: response.role,
requestId: response._request_id
};
} catch (error) {
if (error instanceof Anthropic.APIError) {
// The "model_not_found" error everyone hits
if (error.status === 400 && error.message.includes('model_not_found')) {
throw new ClaudeAPIError(400, 'Invalid model specified. Check model name spelling.');
}
throw new ClaudeAPIError(error.status, error.message);
}
throw error;
}
}
}
Streaming Implementation:
// routes/claude.ts
import { Router } from 'express';
import { MessageProcessor } from '../services/messageProcessor';
const router = Router();
const messageProcessor = new MessageProcessor();
router.post('/stream', async (req, res) => {
// Handle client disconnect or your memory will leak like crazy
const cleanup = () => {
if (!res.headersSent) res.status(499).end();
};
req.on('close', cleanup);
req.on('aborted', cleanup);
try {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('Access-Control-Allow-Origin', '*'); // Streaming breaks without this
const stream = await claude.messages.create({
...req.body,
stream: true
});
for await (const chunk of stream) {
// Bail if user closed their browser
if (req.destroyed) break;
if (chunk.type === 'content_block_delta') {
res.write(`data: ${JSON.stringify(chunk.delta)}
`);
}
}
if (!req.destroyed) {
res.write('data: [DONE]
');
res.end();
}
} catch (error) {
if (!req.destroyed) {
res.write(`data: ${JSON.stringify({ error: error.message })}
`);
res.end();
}
}
});
export { router as claudeRoutes };
Request Validation and Sanitization
Input validation saves your sanity. Claude gives cryptic "invalid request format" errors without it. You'll waste hours debugging requests that look fine to you.
The model name validation prevents the typo everyone makes - "claude-3-sonnet" instead of "claude-sonnet-4-20250514". Spent 45 minutes on this exact bug last month.
// middleware/validation.ts
import { body, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
export const validateMessageRequest = [
body('messages')
.isArray()
.withMessage('Messages must be an array')
.notEmpty()
.withMessage('Messages array cannot be empty'),
body('messages.*.role')
.isIn(['user', 'assistant'])
.withMessage('Message role must be user or assistant'),
body('messages.*.content')
.trim()
.isLength({ min: 1, max: 100000 })
.withMessage('Message content must be between 1 and 100,000 characters'),
body('model')
.optional()
.isIn(['claude-sonnet-4-20250514', 'claude-haiku-3-20241022', 'claude-opus-3-20241022'])
.withMessage('Invalid model - use claude-sonnet-4-20250514, claude-haiku-3-20241022, or claude-opus-3-20241022'),
body('max_tokens')
.optional()
.isInt({ min: 1, max: 8192 })
.withMessage('max_tokens must be between 1 and 8192'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
error: 'Validation failed',
details: errors.array()
});
}
next();
}
];
This setup prevents the disasters everyone hits: leaked keys bankrupting you, memory leaks crashing servers, typos breaking integrations, and validation failures confusing users.
Get this foundation solid before adding fancy shit like Prometheus metrics or Docker. The basics aren't sexy but they prevent 3am pages from angry customers.