April 15, 2026·8 min·By

REST API Design Best Practices: Building APIs Developers Love

REST APIAPI designbackendNode.jsbest practices

A well-designed API is a competitive advantage. A poorly designed one becomes technical debt that every client has to work around forever.

URL Structure

# Resources are nouns, not verbs
GET  /users           # List users
POST /users           # Create user
GET  /users/{id}      # Get one user
PUT  /users/{id}      # Replace user
PATCH /users/{id}     # Partial update
DELETE /users/{id}    # Delete user

# Nested resources for clear ownership
GET /users/{id}/orders
GET /users/{id}/orders/{orderId}

# Actions that don't map to CRUD: use verbs as sub-resources
POST /users/{id}/password-reset
POST /orders/{id}/cancel
POST /invoices/{id}/send

Response Format

// Success: 200 OK
{
    "data": {
        "id": "usr_01HX...",
        "email": "user@example.com",
        "createdAt": "2026-04-15T14:32:00Z"
    }
}

// List: 200 OK with pagination
{
    "data": [...],
    "pagination": {
        "page": 1,
        "perPage": 20,
        "total": 254,
        "hasMore": true,
        "nextCursor": "eyJpZCI6NTB9"
    }
}

// Error: 4xx/5xx
{
    "error": {
        "code": "VALIDATION_ERROR",
        "message": "Email address is invalid",
        "details": [
            { "field": "email", "message": "Must be a valid email address" }
        ]
    }
}

Be consistent. Every response has the same shape.

HTTP Status Codes

200 OK -- successful GET, PUT, PATCH
201 Created -- successful POST that creates a resource
204 No Content -- successful DELETE
400 Bad Request -- invalid input (wrong format, missing fields)
401 Unauthorized -- no auth credentials
403 Forbidden -- valid credentials, but not permitted
404 Not Found -- resource doesn't exist
409 Conflict -- resource already exists, or state conflict
422 Unprocessable Entity -- valid format but semantic errors
429 Too Many Requests -- rate limited
500 Internal Server Error -- unexpected server error

Error Codes

Use a consistent machine-readable error code system:

enum ErrorCode {
    VALIDATION_ERROR = 'VALIDATION_ERROR',
    RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
    DUPLICATE_RESOURCE = 'DUPLICATE_RESOURCE',
    UNAUTHORIZED = 'UNAUTHORIZED',
    FORBIDDEN = 'FORBIDDEN',
    RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
}

function createError(code: ErrorCode, message: string, details?: object) {
    return { error: { code, message, details } }
}

app.get('/users/:id', async (req, res) => {
    const user = await db.users.findById(req.params.id)
    if (!user) {
        return res.status(404).json(
            createError(ErrorCode.RESOURCE_NOT_FOUND, `User ${req.params.id} not found`)
        )
    }
    res.json({ data: user })
})

Pagination

Cursor-based pagination scales better than offset-based:

// Offset pagination (simple but slow on large datasets)
GET /users?page=5&limit=20

// Cursor pagination (fast at any scale)
GET /users?limit=20&cursor=eyJpZCI6NTB9

async function listUsers(limit: number, cursor?: string) {
    let query = db.users.orderBy('id', 'asc').limit(limit + 1)  // +1 to check hasMore

    if (cursor) {
        const { id } = JSON.parse(Buffer.from(cursor, 'base64').toString())
        query = query.where('id', '>', id)
    }

    const users = await query
    const hasMore = users.length > limit

    return {
        data: users.slice(0, limit),
        pagination: {
            hasMore,
            nextCursor: hasMore
                ? Buffer.from(JSON.stringify({ id: users[limit - 1].id })).toString('base64')
                : null
        }
    }
}

Versioning

# URL versioning (most common, easy to understand)
/api/v1/users
/api/v2/users

# Header versioning (cleaner URLs, harder to test in browser)
Accept: application/vnd.myapi.v2+json

Version at the resource level, not the entire API. You can have v2 of /users while /products stays at v1.

Filtering, Sorting, Field Selection

# Filtering
GET /orders?status=active&userId=usr_01HX

# Sorting
GET /users?sort=createdAt:desc,name:asc

# Field selection (reduces response size)
GET /users?fields=id,email,name

# Full-text search
GET /products?q=wireless+headphones

Authentication

# API keys (for server-to-server)
Authorization: Bearer sk_live_xxx...

# JWT (for user sessions)
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...

Always use HTTPS. Always put auth in headers, never in URLs (logs capture URLs).

Rate Limiting Response Headers

X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 987
X-RateLimit-Reset: 1715000000
Retry-After: 60  # (when 429'd)

Rate limit by API key, not IP. IP-based limits break shared offices and proxies.

API Versioning Lifecycle

Document your deprecation policy upfront: announce at least 6 months before removing an endpoint. Add Deprecation and Sunset headers to deprecated endpoints:

Deprecation: Sun, 01 Jan 2027 00:00:00 GMT
Sunset: Sun, 01 Jul 2027 00:00:00 GMT
Link: <https://docs.myapi.com/migration/v1-to-v2>; rel="successor-version"

A great API is one your users want to call, not one they're forced to call.

K
Founder & Technical Lead, Innovibe

Building software for 15+ years. Passionate about AI, system design, and shipping things that work.

Frequently asked questions

How do I handle database migrations safely in production?+

Always run migrations before deploying new code (never after). Use additive-only migrations — add columns with defaults, never rename or drop in the same release. Blue-green deployments make this much safer.

When should I add a Redis cache vs just querying Postgres?+

Add Redis when the same query runs more than ~10 times per second, when query time exceeds 10ms and latency matters, or when you need pub/sub. Don't add it preemptively — optimise the query first.

Does Innovibe build this kind of thing for clients?+

Yes — this is exactly what we do day-to-day for clients across BC and Canada. If you'd rather have us build and maintain it than implement it yourself, reach out.

How do I decide whether to build this in-house or hire an agency?+

Build in-house if your team has the skills and bandwidth and this is core to your product. Hire out if it's infrastructure, if speed matters, or if the expertise gap would take months to close. We're biased, obviously — but we'll tell you honestly when in-house makes more sense.

What tech stack does Innovibe use for projects like this?+

Next.js + TypeScript on the frontend, Node.js or Go on the backend, Postgres for the primary data store, and GCP (Cloud Run, BigQuery, Pub/Sub) for infrastructure. We pick tools that are boring in the best way — proven, well-documented, and easy to hire for.

Building something with AI?

We scope and ship AI features quickly. Let's talk.

Start a Conversation