Build a production-ready REST API backend in Replit in 30-60 minutes using Express, PostgreSQL, and Drizzle ORM. You'll get API key authentication, rate limiting, request logging, and paginated CRUD endpoints — all without leaving your browser.
What you're building
A REST API backend is the foundation of almost every app — it's the layer that accepts requests, validates data, talks to a database, and returns structured responses. If you're new to backend development, this guide teaches the fundamental patterns used in every other Replit build: Express routing, middleware chaining, Drizzle ORM queries, and authentication.
Replit makes this faster than any other environment. Open the Agent tab, describe what you want, and it generates a working Express app connected to the built-in PostgreSQL database. No local Node.js installation, no database setup, no environment configuration — it all runs in your browser.
The architecture you'll build is a reusable foundation: an Express server with three middleware layers (logging → authentication → rate limiting), Drizzle ORM for type-safe database queries, and paginated CRUD endpoints. API consumers authenticate using API keys (Bearer tokens), and every request is logged to PostgreSQL for debugging and analytics.
Final result
A deployed REST API with API key auth, rate limiting, request logging, paginated CRUD, and a health check — ready to be used as the backend for any frontend app or consumed directly by external clients.
Tech stack
Prerequisites
- A Replit account (free tier is sufficient for this guide)
- Basic understanding of what HTTP methods (GET, POST, PUT, DELETE) mean
- No coding experience required — Replit Agent generates all the code
- Optional: a REST client like Postman or curl to test your API endpoints
Build steps
Generate the Express project with Replit Agent
Use Replit Agent to scaffold the complete project in one prompt. Include the exact schema and middleware requirements so Agent generates production-quality code, not a toy example.
1// Prompt to type into Replit Agent:2// Build a Node.js Express REST API with:3// - Built-in PostgreSQL using Drizzle ORM4// - Drizzle schema in shared/schema.ts:5// * api_keys: id serial pk, user_id text not null, key_hash text not null unique,6// name text not null, permissions text[] default ARRAY['read'],7// rate_limit integer default 100, last_used_at timestamp,8// is_active boolean default true, created_at timestamp default now()9// * resources: id serial pk, name text not null, description text,10// category text, metadata jsonb, created_by text not null,11// created_at timestamp default now(), updated_at timestamp default now()12// * request_logs: id serial pk, api_key_id integer references api_keys,13// method text, path text, status_code integer,14// response_time_ms integer, ip_address text, created_at timestamp default now()15// - Three middleware: requestLogger, authenticateApiKey, rateLimit16// - Routes: GET/POST /api/resources, GET/PUT/DELETE /api/resources/:id,17// POST /api/auth/register (generate API key), GET /api/health18// - Pagination via ?page=1&limit=20, filtering via ?category=x, sorting via ?sort=name&order=asc19// - Use Replit Auth for the API key management dashboard20// - Return the raw API key once on creation, only store SHA-256 hashPro tip: After Agent finishes, open server/index.js and check the middleware order. It should be: requestLogger → authenticateApiKey (on protected routes) → rateLimit → route handler. If the order is wrong, ask Agent to fix it.
Expected result: A running Express server with the three middleware layers and all CRUD routes. The Shell panel shows 'Server running on port 3000'.
Implement the API key authentication middleware
This middleware extracts the key from the Authorization header, hashes it, looks it up in the database, and attaches the key's permissions to the request. Never store plain-text API keys.
1import crypto from 'crypto';2import { db } from '../db.js';3import { apiKeys, requestLogs } from '../../shared/schema.js';4import { eq, and } from 'drizzle-orm';56function hashKey(rawKey) {7 return crypto.createHash('sha256').update(rawKey).digest('hex');8}910export async function authenticateApiKey(req, res, next) {11 const authHeader = req.headers.authorization;12 if (!authHeader || !authHeader.startsWith('Bearer ')) {13 return res.status(401).json({ error: 'Missing or invalid Authorization header. Use: Bearer <api_key>' });14 }1516 const rawKey = authHeader.slice(7);17 const keyHash = hashKey(rawKey);1819 try {20 const [apiKey] = await db21 .select()22 .from(apiKeys)23 .where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true)))24 .limit(1);2526 if (!apiKey) {27 return res.status(401).json({ error: 'Invalid or revoked API key' });28 }2930 // Update last_used_at asynchronously — don't block the request31 db.update(apiKeys)32 .set({ lastUsedAt: new Date() })33 .where(eq(apiKeys.id, apiKey.id))34 .catch(console.error);3536 req.apiKey = apiKey;37 next();38 } catch (err) {39 res.status(500).json({ error: 'Authentication failed' });40 }41}Pro tip: When a user registers for an API key (POST /api/auth/register), generate the raw key with crypto.randomBytes(32).toString('hex'), return it in the response exactly once, and store only the hash. Add a note in the response: 'Save this key — it will not be shown again.'
Expected result: Requests with a valid Bearer token proceed to the route handler. Requests without a token or with an invalid token receive a 401 JSON response.
Add rate limiting using request_logs
Count how many requests this API key has made in the last hour. If it exceeds the key's rate_limit, return 429 with a Retry-After header showing when the oldest request expires.
1import { db } from '../db.js';2import { requestLogs } from '../../shared/schema.js';3import { eq, gte, count } from 'drizzle-orm';45export async function rateLimit(req, res, next) {6 if (!req.apiKey) return next(); // skip if no key (unauthenticated routes)78 const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);910 try {11 const [result] = await db12 .select({ count: count() })13 .from(requestLogs)14 .where(15 eq(requestLogs.apiKeyId, req.apiKey.id),16 gte(requestLogs.createdAt, oneHourAgo)17 );1819 const requestCount = Number(result.count);20 const limit = req.apiKey.rateLimit;2122 if (requestCount >= limit) {23 const retryAfter = 3600 - Math.floor((Date.now() - oneHourAgo.getTime()) / 1000);24 res.set('Retry-After', String(retryAfter));25 res.set('X-RateLimit-Limit', String(limit));26 res.set('X-RateLimit-Remaining', '0');27 return res.status(429).json({28 error: `Rate limit exceeded. Limit: ${limit} requests/hour. Try again in ${retryAfter} seconds.`29 });30 }3132 res.set('X-RateLimit-Limit', String(limit));33 res.set('X-RateLimit-Remaining', String(limit - requestCount - 1));34 next();35 } catch (err) {36 next(); // fail open — don't block requests if rate limit check fails37 }38}Expected result: After making more than 100 requests with one API key within an hour, the API returns HTTP 429 with a Retry-After header. The X-RateLimit-Remaining header counts down on each request.
Build the paginated list endpoint with filtering and sorting
The GET /api/resources endpoint is the most-used route. Build it to support pagination, category filtering, and sorting from the start — retrofitting these later is painful.
1import { db } from '../db.js';2import { resources } from '../../shared/schema.js';3import { eq, like, asc, desc, count } from 'drizzle-orm';45export async function listResources(req, res) {6 const page = Math.max(1, parseInt(req.query.page) || 1);7 const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 20));8 const offset = (page - 1) * limit;9 const category = req.query.category;10 const search = req.query.search;11 const sortField = ['name', 'created_at', 'category'].includes(req.query.sort)12 ? req.query.sort : 'created_at';13 const sortDir = req.query.order === 'asc' ? asc : desc;1415 try {16 let query = db.select().from(resources);17 let countQuery = db.select({ count: count() }).from(resources);1819 if (category) {20 query = query.where(eq(resources.category, category));21 countQuery = countQuery.where(eq(resources.category, category));22 }2324 const [{ count: total }] = await countQuery;25 const items = await query26 .orderBy(sortDir(resources[sortField === 'created_at' ? 'createdAt' : sortField]))27 .limit(limit)28 .offset(offset);2930 res.json({31 data: items,32 pagination: {33 page,34 limit,35 total: Number(total),36 totalPages: Math.ceil(Number(total) / limit),37 hasNext: page * limit < Number(total),38 },39 });40 } catch (err) {41 res.status(500).json({ error: 'Failed to fetch resources' });42 }43}Pro tip: Cap the limit parameter at 100 (as shown) to prevent clients from fetching thousands of rows in one request. This is a common API design mistake that causes slow queries and high memory usage.
Expected result: GET /api/resources?page=2&limit=10&category=tech returns the second page of 10 resources in the 'tech' category, plus a pagination object showing total count and whether there's a next page.
Test your API and deploy on Autoscale
Use Replit's built-in webview to test the health endpoint, then deploy on Autoscale for a persistent URL that you can share with API consumers.
1// Test your API from the Replit Shell (these are server-side tests, not terminal commands):2// Use the Webview tab or test from any HTTP client with your deployed URL34// GET /api/health — should return 200 with status: 'ok'5// Response: { "status": "ok", "uptime": 42.3, "version": "1.0.0" }67// POST /api/auth/register — create your first API key8// Body: { "name": "My App Key" }9// Response: { "key": "abc123...", "message": "Save this key — it won't be shown again" }1011// GET /api/resources — use the key you just created12// Header: Authorization: Bearer abc123...13// Response: { "data": [], "pagination": { "total": 0, ... } }1415// Deploy: Click Deploy in the top-right → Autoscale → Deploy16// Add Secrets in the Deployment panel (not workspace Secrets)17// Your API gets a permanent URL like: https://your-app.repl.coPro tip: After deploying, test the rate limiter by making 101 requests quickly. You should see the 429 response. Then check the request_logs table in Drizzle Studio to confirm all 101 requests were logged.
Expected result: The deployed API returns proper JSON responses for all endpoints. The rate limiter returns 429 after the configured limit. All requests appear in the request_logs table.
Complete code
1import crypto from 'crypto';2import { db } from '../db.js';3import { apiKeys } from '../../shared/schema.js';4import { and, eq } from 'drizzle-orm';56export function hashApiKey(rawKey) {7 return crypto.createHash('sha256').update(rawKey).digest('hex');8}910export function generateApiKey() {11 return 'rk_' + crypto.randomBytes(32).toString('hex');12}1314export async function authenticateApiKey(req, res, next) {15 const authHeader = req.headers.authorization;1617 if (!authHeader?.startsWith('Bearer ')) {18 return res.status(401).json({19 error: 'Missing Authorization header',20 hint: 'Use: Authorization: Bearer <your_api_key>',21 });22 }2324 const rawKey = authHeader.slice(7).trim();25 if (!rawKey) {26 return res.status(401).json({ error: 'Empty API key' });27 }2829 const keyHash = hashApiKey(rawKey);3031 try {32 const [apiKey] = await db33 .select()34 .from(apiKeys)35 .where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true)))36 .limit(1);3738 if (!apiKey) {39 return res.status(401).json({ error: 'Invalid or revoked API key' });40 }4142 // Non-blocking last_used update43 db.update(apiKeys)44 .set({ lastUsedAt: new Date() })45 .where(eq(apiKeys.id, apiKey.id))46 .catch(err => console.error('Failed to update last_used_at:', err));4748 req.apiKey = apiKey;49 next();50 } catch (err) {51 console.error('Auth middleware error:', err);52 res.status(500).json({ error: 'Authentication service unavailable' });53 }54}Customization ideas
Permission-scoped API keys
Use the permissions text[] column on api_keys to restrict keys to specific operations. Add a requirePermission('write') middleware that checks req.apiKey.permissions includes the required permission before allowing POST/PUT/DELETE.
Auto-generated API documentation
Add a GET /api/docs endpoint that returns a JSON object describing all routes, their parameters, and example responses. Build a simple HTML page at /docs that renders this JSON as readable documentation — a lightweight Swagger alternative.
Webhook outbound calls
Add a webhooks table with endpoint_url and event_types columns. When resources are created or updated, the route handler checks for matching webhook subscriptions and makes outbound POST requests to the registered URLs.
Common pitfalls
Pitfall: Returning the API key hash instead of the raw key
How to avoid: Generate the raw key, return it in the registration response with a warning to save it, then store only crypto.createHash('sha256').update(rawKey).digest('hex') in the database.
Pitfall: Not adding indexes on request_logs.api_key_id and created_at
How to avoid: Add a compound index: CREATE INDEX idx_logs_key_time ON request_logs (api_key_id, created_at). This makes the rate limit check fast even with millions of log rows.
Pitfall: Trusting client-supplied category or sort values directly in SQL
How to avoid: Whitelist allowed values: const sortField = ['name', 'created_at', 'category'].includes(req.query.sort) ? req.query.sort : 'created_at'. Never pass raw query params directly to Drizzle column references.
Best practices
- Never store plain-text API keys — always hash with SHA-256 before inserting into the database.
- Return the raw API key exactly once (on creation) with a clear message telling the user to save it.
- Cap the ?limit= query param at a reasonable maximum (100) to prevent unbounded queries.
- Use withRetry() on all database calls to handle Replit's PostgreSQL idle sleep reconnection.
- Add X-RateLimit-Limit and X-RateLimit-Remaining headers to every response so API consumers know their usage.
- Deploy on Autoscale — REST APIs have variable traffic and scale-to-zero keeps costs near zero when idle.
- Log every request to request_logs — this doubles as both a rate limiting source and a debugging tool.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a REST API in Express with API key authentication. Each API key is stored as a SHA-256 hash in PostgreSQL. Help me write an Express middleware that extracts the Bearer token from the Authorization header, hashes it, looks it up in the database using Drizzle ORM, and attaches the key record to req.apiKey. It should return 401 for missing headers, 401 for invalid keys, and pass through to next() for valid keys.
Add versioned API routing to the Express app. Move all current routes under /api/v1/. Create an /api/v2/ router with the same endpoints but a different response format (v2 wraps responses in a meta envelope with timestamps and request IDs). Both versions share the same Drizzle database calls — only the route handlers and response shapes differ. Add an Accept-Version header check that routes to v1 or v2 based on the client's preference.
Frequently asked questions
Can I use this API backend with any frontend framework?
Yes. Express serves JSON from any route, so your frontend can be React, Vue, Svelte, a mobile app, or even another server. Set CORS headers using the cors npm package to allow cross-origin requests from your frontend domain.
How do I add CORS support so my frontend can call the API?
Install cors with npm (Replit handles npm install automatically when you add it to package.json). Add app.use(cors({ origin: 'https://your-frontend.com' })) before your routes. Use origin: '*' during development and restrict to specific domains in production.
Is the free Replit tier enough for a real API?
Yes for development and low-traffic production use. The free tier includes Drizzle ORM support and the built-in PostgreSQL. For higher traffic (1000+ daily users), consider Replit Core for dedicated compute, or deploy to a platform like Railway using the same Express code.
Why is the first request after idle slow?
Replit's built-in PostgreSQL sleeps after 5 minutes of inactivity. The first request wakes it up, which takes 1-3 seconds. The withRetry() wrapper handles the reconnection automatically. Subsequent requests are fast until the next idle period.
How do I add input validation to POST and PUT routes?
Use the zod library for schema validation. Define a schema like z.object({ name: z.string().min(1), category: z.string().optional() }), then call schema.parse(req.body) in your route handler. Zod throws a ZodError if validation fails — catch it and return a 400 response with the validation messages.
Can RapidDev help build a custom API backend for my product?
Yes. RapidDev has built 600+ backends including APIs with complex authentication flows, multi-tenancy, webhooks, and third-party integrations. Contact us for a free consultation about your specific requirements.
Should I use Autoscale or Reserved VM for my API?
Autoscale for most APIs. It scales to zero when idle (very cost-effective) and cold starts take 5-15 seconds on first request after idle. If your API receives webhooks from external services (Stripe, GitHub, etc.), use Reserved VM — webhooks can't wait 15 seconds for a cold start.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation