# How to Build a API Backend with Replit

- Tool: How to Build with Replit
- Difficulty: Beginner
- Compatibility: Replit Free
- Last updated: April 2026

## 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.

## Before you start

- 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

## Step-by-step guide

### 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 to type into Replit Agent:
// Build a Node.js Express REST API with:
// - Built-in PostgreSQL using Drizzle ORM
// - Drizzle schema in shared/schema.ts:
//   * api_keys: id serial pk, user_id text not null, key_hash text not null unique,
//     name text not null, permissions text[] default ARRAY['read'],
//     rate_limit integer default 100, last_used_at timestamp,
//     is_active boolean default true, created_at timestamp default now()
//   * resources: id serial pk, name text not null, description text,
//     category text, metadata jsonb, created_by text not null,
//     created_at timestamp default now(), updated_at timestamp default now()
//   * request_logs: id serial pk, api_key_id integer references api_keys,
//     method text, path text, status_code integer,
//     response_time_ms integer, ip_address text, created_at timestamp default now()
// - Three middleware: requestLogger, authenticateApiKey, rateLimit
// - Routes: GET/POST /api/resources, GET/PUT/DELETE /api/resources/:id,
//   POST /api/auth/register (generate API key), GET /api/health
// - Pagination via ?page=1&limit=20, filtering via ?category=x, sorting via ?sort=name&order=asc
// - Use Replit Auth for the API key management dashboard
// - 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.

```
import crypto from 'crypto';
import { db } from '../db.js';
import { apiKeys, requestLogs } from '../../shared/schema.js';
import { eq, and } from 'drizzle-orm';

function hashKey(rawKey) {
  return crypto.createHash('sha256').update(rawKey).digest('hex');
}

export async function authenticateApiKey(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or invalid Authorization header. Use: Bearer <api_key>' });
  }

  const rawKey = authHeader.slice(7);
  const keyHash = hashKey(rawKey);

  try {
    const [apiKey] = await db
      .select()
      .from(apiKeys)
      .where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true)))
      .limit(1);

    if (!apiKey) {
      return res.status(401).json({ error: 'Invalid or revoked API key' });
    }

    // Update last_used_at asynchronously — don't block the request
    db.update(apiKeys)
      .set({ lastUsedAt: new Date() })
      .where(eq(apiKeys.id, apiKey.id))
      .catch(console.error);

    req.apiKey = apiKey;
    next();
  } catch (err) {
    res.status(500).json({ error: 'Authentication failed' });
  }
}
```

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

```
import { db } from '../db.js';
import { requestLogs } from '../../shared/schema.js';
import { eq, gte, count } from 'drizzle-orm';

export async function rateLimit(req, res, next) {
  if (!req.apiKey) return next(); // skip if no key (unauthenticated routes)

  const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);

  try {
    const [result] = await db
      .select({ count: count() })
      .from(requestLogs)
      .where(
        eq(requestLogs.apiKeyId, req.apiKey.id),
        gte(requestLogs.createdAt, oneHourAgo)
      );

    const requestCount = Number(result.count);
    const limit = req.apiKey.rateLimit;

    if (requestCount >= limit) {
      const retryAfter = 3600 - Math.floor((Date.now() - oneHourAgo.getTime()) / 1000);
      res.set('Retry-After', String(retryAfter));
      res.set('X-RateLimit-Limit', String(limit));
      res.set('X-RateLimit-Remaining', '0');
      return res.status(429).json({
        error: `Rate limit exceeded. Limit: ${limit} requests/hour. Try again in ${retryAfter} seconds.`
      });
    }

    res.set('X-RateLimit-Limit', String(limit));
    res.set('X-RateLimit-Remaining', String(limit - requestCount - 1));
    next();
  } catch (err) {
    next(); // fail open — don't block requests if rate limit check fails
  }
}
```

**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.

```
import { db } from '../db.js';
import { resources } from '../../shared/schema.js';
import { eq, like, asc, desc, count } from 'drizzle-orm';

export async function listResources(req, res) {
  const page = Math.max(1, parseInt(req.query.page) || 1);
  const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 20));
  const offset = (page - 1) * limit;
  const category = req.query.category;
  const search = req.query.search;
  const sortField = ['name', 'created_at', 'category'].includes(req.query.sort)
    ? req.query.sort : 'created_at';
  const sortDir = req.query.order === 'asc' ? asc : desc;

  try {
    let query = db.select().from(resources);
    let countQuery = db.select({ count: count() }).from(resources);

    if (category) {
      query = query.where(eq(resources.category, category));
      countQuery = countQuery.where(eq(resources.category, category));
    }

    const [{ count: total }] = await countQuery;
    const items = await query
      .orderBy(sortDir(resources[sortField === 'created_at' ? 'createdAt' : sortField]))
      .limit(limit)
      .offset(offset);

    res.json({
      data: items,
      pagination: {
        page,
        limit,
        total: Number(total),
        totalPages: Math.ceil(Number(total) / limit),
        hasNext: page * limit < Number(total),
      },
    });
  } catch (err) {
    res.status(500).json({ error: 'Failed to fetch resources' });
  }
}
```

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

```
// Test your API from the Replit Shell (these are server-side tests, not terminal commands):
// Use the Webview tab or test from any HTTP client with your deployed URL

// GET /api/health — should return 200 with status: 'ok'
// Response: { "status": "ok", "uptime": 42.3, "version": "1.0.0" }

// POST /api/auth/register — create your first API key
// Body: { "name": "My App Key" }
// Response: { "key": "abc123...", "message": "Save this key — it won't be shown again" }

// GET /api/resources — use the key you just created
// Header: Authorization: Bearer abc123...
// Response: { "data": [], "pagination": { "total": 0, ... } }

// Deploy: Click Deploy in the top-right → Autoscale → Deploy
// Add Secrets in the Deployment panel (not workspace Secrets)
// 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 example

File: `server/middleware/authenticateApiKey.js`

```javascript
import crypto from 'crypto';
import { db } from '../db.js';
import { apiKeys } from '../../shared/schema.js';
import { and, eq } from 'drizzle-orm';

export function hashApiKey(rawKey) {
  return crypto.createHash('sha256').update(rawKey).digest('hex');
}

export function generateApiKey() {
  return 'rk_' + crypto.randomBytes(32).toString('hex');
}

export async function authenticateApiKey(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({
      error: 'Missing Authorization header',
      hint: 'Use: Authorization: Bearer <your_api_key>',
    });
  }

  const rawKey = authHeader.slice(7).trim();
  if (!rawKey) {
    return res.status(401).json({ error: 'Empty API key' });
  }

  const keyHash = hashApiKey(rawKey);

  try {
    const [apiKey] = await db
      .select()
      .from(apiKeys)
      .where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true)))
      .limit(1);

    if (!apiKey) {
      return res.status(401).json({ error: 'Invalid or revoked API key' });
    }

    // Non-blocking last_used update
    db.update(apiKeys)
      .set({ lastUsedAt: new Date() })
      .where(eq(apiKeys.id, apiKey.id))
      .catch(err => console.error('Failed to update last_used_at:', err));

    req.apiKey = apiKey;
    next();
  } catch (err) {
    console.error('Auth middleware error:', err);
    res.status(500).json({ error: 'Authentication service unavailable' });
  }
}
```

## Common mistakes

- **Returning the API key hash instead of the raw key** — The hash is what you store — the raw key is what the user needs to authenticate. If you return the hash, the user can never authenticate. Fix: 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.
- **Not adding indexes on request_logs.api_key_id and created_at** — The rate limiter counts rows in request_logs for the last hour on every request. Without indexes, this query scans the full table and slows down as the table grows. Fix: 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.
- **Trusting client-supplied category or sort values directly in SQL** — Without validation, a malicious client can inject SQL fragments or cause unexpected query behavior. Fix: 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.

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-replit/api-backend
© RapidDev — https://www.rapidevelopers.com/how-to-build-replit/api-backend
