Skip to main content
RapidDev - Software Development Agency
how-to-build-replit30-60 minutes

How to Build a API Backend with Replit

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'll build

  • Express REST API with full CRUD endpoints: GET (list + single), POST, PUT, DELETE
  • API key authentication middleware that hashes keys with SHA-256 and never stores plain text
  • Per-key rate limiting using request_logs count — returns 429 with Retry-After header when exceeded
  • PostgreSQL schema via Drizzle ORM with api_keys, resources, and request_logs tables
  • Request logging middleware that records method, path, status code, and response time on every call
  • Paginated list endpoint with filtering and sorting via query params (?page=1&limit=20&sort=name)
  • Health check endpoint at GET /api/health returning uptime and version
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner11 min read30-60 minutesReplit FreeApril 2026RapidDev Engineering Team
TL;DR

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

ReplitIDE & Hosting
ExpressBackend Framework
PostgreSQLDatabase
Drizzle ORMDatabase ORM
Replit AuthAuth (for dashboard only)

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

1

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.

prompt.txt
1// Prompt to type into Replit Agent:
2// Build a Node.js Express REST API with:
3// - Built-in PostgreSQL using Drizzle ORM
4// - 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, rateLimit
16// - Routes: GET/POST /api/resources, GET/PUT/DELETE /api/resources/:id,
17// POST /api/auth/register (generate API key), GET /api/health
18// - Pagination via ?page=1&limit=20, filtering via ?category=x, sorting via ?sort=name&order=asc
19// - Use Replit Auth for the API key management dashboard
20// - Return the raw API key once on creation, only store SHA-256 hash

Pro 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'.

2

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.

server/middleware/authenticateApiKey.js
1import crypto from 'crypto';
2import { db } from '../db.js';
3import { apiKeys, requestLogs } from '../../shared/schema.js';
4import { eq, and } from 'drizzle-orm';
5
6function hashKey(rawKey) {
7 return crypto.createHash('sha256').update(rawKey).digest('hex');
8}
9
10export 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 }
15
16 const rawKey = authHeader.slice(7);
17 const keyHash = hashKey(rawKey);
18
19 try {
20 const [apiKey] = await db
21 .select()
22 .from(apiKeys)
23 .where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true)))
24 .limit(1);
25
26 if (!apiKey) {
27 return res.status(401).json({ error: 'Invalid or revoked API key' });
28 }
29
30 // Update last_used_at asynchronously — don't block the request
31 db.update(apiKeys)
32 .set({ lastUsedAt: new Date() })
33 .where(eq(apiKeys.id, apiKey.id))
34 .catch(console.error);
35
36 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.

3

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.

server/middleware/rateLimit.js
1import { db } from '../db.js';
2import { requestLogs } from '../../shared/schema.js';
3import { eq, gte, count } from 'drizzle-orm';
4
5export async function rateLimit(req, res, next) {
6 if (!req.apiKey) return next(); // skip if no key (unauthenticated routes)
7
8 const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
9
10 try {
11 const [result] = await db
12 .select({ count: count() })
13 .from(requestLogs)
14 .where(
15 eq(requestLogs.apiKeyId, req.apiKey.id),
16 gte(requestLogs.createdAt, oneHourAgo)
17 );
18
19 const requestCount = Number(result.count);
20 const limit = req.apiKey.rateLimit;
21
22 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 }
31
32 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 fails
37 }
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.

4

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.

server/routes/resources.js
1import { db } from '../db.js';
2import { resources } from '../../shared/schema.js';
3import { eq, like, asc, desc, count } from 'drizzle-orm';
4
5export 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;
14
15 try {
16 let query = db.select().from(resources);
17 let countQuery = db.select({ count: count() }).from(resources);
18
19 if (category) {
20 query = query.where(eq(resources.category, category));
21 countQuery = countQuery.where(eq(resources.category, category));
22 }
23
24 const [{ count: total }] = await countQuery;
25 const items = await query
26 .orderBy(sortDir(resources[sortField === 'created_at' ? 'createdAt' : sortField]))
27 .limit(limit)
28 .offset(offset);
29
30 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.

5

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.

prompt.txt
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 URL
3
4// GET /api/health — should return 200 with status: 'ok'
5// Response: { "status": "ok", "uptime": 42.3, "version": "1.0.0" }
6
7// POST /api/auth/register — create your first API key
8// Body: { "name": "My App Key" }
9// Response: { "key": "abc123...", "message": "Save this key — it won't be shown again" }
10
11// GET /api/resources — use the key you just created
12// Header: Authorization: Bearer abc123...
13// Response: { "data": [], "pagination": { "total": 0, ... } }
14
15// Deploy: Click Deploy in the top-right → Autoscale → Deploy
16// Add Secrets in the Deployment panel (not workspace Secrets)
17// Your API gets a permanent URL like: https://your-app.repl.co

Pro 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

server/middleware/authenticateApiKey.js
1import crypto from 'crypto';
2import { db } from '../db.js';
3import { apiKeys } from '../../shared/schema.js';
4import { and, eq } from 'drizzle-orm';
5
6export function hashApiKey(rawKey) {
7 return crypto.createHash('sha256').update(rawKey).digest('hex');
8}
9
10export function generateApiKey() {
11 return 'rk_' + crypto.randomBytes(32).toString('hex');
12}
13
14export async function authenticateApiKey(req, res, next) {
15 const authHeader = req.headers.authorization;
16
17 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 }
23
24 const rawKey = authHeader.slice(7).trim();
25 if (!rawKey) {
26 return res.status(401).json({ error: 'Empty API key' });
27 }
28
29 const keyHash = hashApiKey(rawKey);
30
31 try {
32 const [apiKey] = await db
33 .select()
34 .from(apiKeys)
35 .where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true)))
36 .limit(1);
37
38 if (!apiKey) {
39 return res.status(401).json({ error: 'Invalid or revoked API key' });
40 }
41
42 // Non-blocking last_used update
43 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));
47
48 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.

ChatGPT Prompt

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.

Build Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.