REST API Best Practices 2026

Learn the essential REST API best practices for 2026, from HTTP/3 and OpenAPI 3.1 to JSON:API vs custom JSON, cursor pagination, idempotency keys, robust security, and performance optimizations that make your APIs fast, secure, and developer-friendly.

TRY NANO BANANA FOR FREE

REST API Best Practices 2026

TRY NANO BANANA FOR FREE
Contents

REST APIs have been around for over two decades, but the way we build them keeps evolving. What worked in 2021 might not cut it in 2026.

HTTP/3 is now mainstream. OpenAPI 3.1 brought JSON Schema compatibility. New standards like JSON:API gained traction while GraphQL's hype cooled. Developer expectations shifted toward better DX and faster performance.

This guide covers what's changed in REST API design over the last five years and what best practices matter most in 2026.

What's New in 2026

Before diving into best practices, let's look at what's actually different from five years ago.

HTTP/3 Is Now Standard

HTTP/3 replaced TCP with QUIC, bringing:

  • Faster connections: No more TCP handshake delays
  • Better mobile performance: Handles network switches seamlessly
  • Reduced latency: Multiplexing without head-of-line blocking

Most CDNs and cloud providers now support HTTP/3 by default. If your API doesn't, you're leaving performance on the table.

Enable HTTP/3 in Nginx:

server {
    listen 443 quic reuseport;
    listen 443 ssl;

    http3 on;
    http3_hq on;
    quic_retry on;

    ssl_certificate /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    ssl_protocols TLSv1.3;
    ssl_early_data on;

    add_header Alt-Svc 'h3=":443"; ma=86400';

    location /api {
        proxy_pass http://backend;
        proxy_http_version 1.1;
    }
}

The Alt-Svc header tells clients HTTP/3 is available. Browsers automatically upgrade on subsequent requests.

OpenAPI 3.1 Unified with JSON Schema

OpenAPI 3.1 adopted JSON Schema 2020-12, ending years of schema fragmentation.

Old way (OpenAPI 3.0):

components:
  schemas:
    Pet:
      type: object
      required:
        - name
      properties:
        id:
          type: integer
          format: int64
        name:
          type: string
          minLength: 1
        status:
          type: string
          enum: [available, pending, sold]

New way (OpenAPI 3.1):

components:
  schemas:
    Pet:
      type: object
      required:
        - name
      properties:
        id:
          type: integer
          format: int64
        name:
          type: string
          minLength: 1
        status:
          type: string
          enum: [available, pending, sold]
      examples:
        - id: 1
          name: "Buddy"
          status: "available"

The syntax looks similar, but 3.1 supports:

  • Native JSON Schema: Use any JSON Schema validator
  • Discriminators: Better polymorphism support
  • Webhooks: Document callback endpoints
  • Null types: type: [string, null] instead of nullable: true

Update your specs to 3.1. Most tooling now supports it.

JSON:API vs Custom Formats

Five years ago, everyone built custom JSON formats. Now there's a split:

JSON:API gained adoption for its standardization:

{
  "data": {
    "type": "pets",
    "id": "1",
    "attributes": {
      "name": "Buddy",
      "status": "available"
    },
    "relationships": {
      "owner": {
        "data": { "type": "users", "id": "42" }
      }
    }
  },
  "included": [
    {
      "type": "users",
      "id": "42",
      "attributes": {
        "name": "John Doe"
      }
    }
  ]
}

Custom formats stayed popular for simplicity:

{
  "id": 1,
  "name": "Buddy",
  "status": "available",
  "owner": {
    "id": 42,
    "name": "John Doe"
  }
}

Which should you use?

Choose JSON:API if:- You have complex relationships - You want standardized filtering/sorting - You're building a large API with many resources - You want client libraries that work out of the box

Choose custom JSON if:- You want simple, readable responses - Your API is small to medium sized - You need flexibility in response structure - You're optimizing for mobile bandwidth

There's no wrong choice. Just pick one and stay consistent.

GraphQL Hype Cooled, REST Stayed Strong

Five years ago, everyone was migrating to GraphQL. In 2026, many migrated back.

GraphQL's problems became clear at scale:

  • N+1 queries: Easy to write, hard to optimize
  • Caching complexity: CDNs can't cache dynamic queries
  • Security concerns: Query depth attacks and field-level auth
  • Tooling overhead: More complex than REST

REST APIs with good design often beat GraphQL for:

  • Public APIs: Easier to document and cache
  • Simple CRUD: REST is more straightforward
  • Mobile apps: Predefined endpoints use less bandwidth
  • Third-party integrations: Everyone knows REST

GraphQL still wins for:

  • Complex UIs: Fetch exactly what you need
  • Rapid prototyping: No backend changes for new queries
  • Internal APIs: When you control both client and server

Most companies now run both: REST for public APIs, GraphQL for internal tools.

Core REST Principles That Still Matter

Some things haven't changed. These principles still separate good APIs from great ones.

1. Use HTTP Methods Correctly

This hasn't changed, but it's still commonly done wrong.

GET: Retrieve data, no side effects

GET /api/v1/pets/1

POST: Create new resources

POST /api/v1/pets
Content-Type: application/json

{
  "name": "Buddy",
  "status": "available"
}

PUT: Replace entire resource

PUT /api/v1/pets/1
Content-Type: application/json

{
  "id": 1,
  "name": "Buddy",
  "status": "sold",
  "category": "dog"
}

PATCH: Partial update

PATCH /api/v1/pets/1
Content-Type: application/json

{
  "status": "sold"
}

DELETE: Remove resource

DELETE /api/v1/pets/1

Common mistakes:

  • Using GET for actions that change data
  • Using POST for everything
  • Using PUT when you mean PATCH
  • Not supporting PATCH at all

2. Design Intuitive Resource URLs

Good URL design makes APIs self-documenting.

Good:

GET    /api/v1/pets
POST   /api/v1/pets
GET    /api/v1/pets/1
PUT    /api/v1/pets/1
DELETE /api/v1/pets/1
GET    /api/v1/pets/1/orders

Bad:

GET    /api/v1/getAllPets
POST   /api/v1/createNewPet
GET    /api/v1/pet?id=1
POST   /api/v1/updatePet
POST   /api/v1/deletePet
GET    /api/v1/getOrdersForPet?petId=1

Rules that work:

  • Use nouns, not verbs
  • Use plural names for collections
  • Nest resources logically
  • Keep URLs short and readable
  • Use hyphens, not underscores
  • Lowercase everything

3. Version Your API

Breaking changes happen. Versioning lets you evolve without breaking clients.

URL versioning (most common):

https://api.example.com/v1/pets
https://api.example.com/v2/pets

Header versioning (cleaner URLs):

GET /api/pets
Accept: application/vnd.petstore.v2+json

Query parameter versioning (easiest for testing):

https://api.example.com/pets?version=2

Pick one and stick with it. URL versioning is most popular because it's explicit and easy to route.

When to bump versions:

  • Removing fields
  • Changing field types
  • Changing URL structure
  • Changing authentication

When not to bump:

  • Adding optional fields
  • Adding new endpoints
  • Fixing bugs
  • Improving performance

4. Return Proper HTTP Status Codes

Status codes communicate what happened without reading the response body.

Success codes:

  • 200 OK: Request succeeded, returning data
  • 201 Created: Resource created successfully
  • 202 Accepted: Request accepted, processing async
  • 204 No Content: Success, no response body

Client error codes:

  • 400 Bad Request: Invalid input
  • 401 Unauthorized: Missing or invalid auth
  • 403 Forbidden: Authenticated but not allowed
  • 404 Not Found: Resource doesn't exist
  • 409 Conflict: Request conflicts with current state
  • 422 Unprocessable Entity: Validation failed
  • 429 Too Many Requests: Rate limit exceeded

Server error codes:

  • 500 Internal Server Error: Something broke
  • 502 Bad Gateway: Upstream service failed
  • 503 Service Unavailable: Temporarily down
  • 504 Gateway Timeout: Upstream service timeout

Example error response:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid pet data",
    "details": [
      {
        "field": "name",
        "message": "Name is required"
      },
      {
        "field": "status",
        "message": "Status must be one of: available, pending, sold"
      }
    ]
  }
}

Consistent error format helps clients handle failures gracefully.

Modern API Design Patterns

These patterns emerged or matured over the last five years.

1. Pagination with Cursor-Based Navigation

Offset pagination breaks with real-time data:

GET /api/v1/pets?limit=20&offset=40

If items are added/deleted between requests, you get duplicates or miss items.

Cursor pagination solves this:

GET /api/v1/pets?limit=20&cursor=eyJpZCI6NDAsInRpbWVzdGFtcCI6MTY0MjU5NjAwMH0

Response:

{
  "data": [
    { "id": 41, "name": "Buddy" },
    { "id": 42, "name": "Max" }
  ],
  "pagination": {
    "next_cursor": "eyJpZCI6NjAsInRpbWVzdGFtcCI6MTY0MjU5NjEwMH0",
    "has_more": true
  }
}

The cursor encodes the position in the dataset. Even if data changes, pagination stays consistent.

Implementation:

// Express.js example
app.get('/api/v1/pets', async (req, res) => {
  const limit = parseInt(req.query.limit) || 20;
  const cursor = req.query.cursor ? decodeCursor(req.query.cursor) : null;

  let query = db.pets.orderBy('id', 'asc').limit(limit + 1);

  if (cursor) {
    query = query.where('id', '>', cursor.id);
  }

  const pets = await query;
  const hasMore = pets.length > limit;

  if (hasMore) {
    pets.pop(); // Remove extra item
  }

  const nextCursor = hasMore ? encodeCursor({ id: pets[pets.length - 1].id }) : null;

  res.json({
    data: pets,
    pagination: {
      next_cursor: nextCursor,
      has_more: hasMore
    }
  });
});

function encodeCursor(data) {
  return Buffer.from(JSON.stringify(data)).toString('base64');
}

function decodeCursor(cursor) {
  return JSON.parse(Buffer.from(cursor, 'base64').toString());
}

2. Field Selection for Bandwidth Optimization

Mobile clients don't need every field. Let them choose:

GET /api/v1/pets?fields=id,name,status

Response:

{
  "data": [
    { "id": 1, "name": "Buddy", "status": "available" },
    { "id": 2, "name": "Max", "status": "pending" }
  ]
}

Implementation:

app.get('/api/v1/pets', async (req, res) => {
  const fields = req.query.fields ? req.query.fields.split(',') : null;

  let query = db.pets.select();

  if (fields) {
    query = query.select(fields);
  }

  const pets = await query;
  res.json({ data: pets });
});

This cuts response sizes by 50-80% for mobile apps.

3. Filtering and Sorting Standards

Standardize query parameters:

Filtering:

GET /api/v1/pets?status=available&category=dog
GET /api/v1/pets?status[in]=available,pending
GET /api/v1/pets?created_at[gte]=2026-01-01

Sorting:

GET /api/v1/pets?sort=name
GET /api/v1/pets?sort=-created_at  # Descending
GET /api/v1/pets?sort=status,-name  # Multiple fields

Implementation:

app.get('/api/v1/pets', async (req, res) => {
  let query = db.pets.select();

  // Filtering
  if (req.query.status) {
    query = query.where('status', req.query.status);
  }

  if (req.query.category) {
    query = query.where('category', req.query.category);
  }

  // Sorting
  if (req.query.sort) {
    const sortFields = req.query.sort.split(',');
    sortFields.forEach(field => {
      const direction = field.startsWith('-') ? 'desc' : 'asc';
      const fieldName = field.replace(/^-/, '');
      query = query.orderBy(fieldName, direction);
    });
  }

  const pets = await query;
  res.json({ data: pets });
});

4. Rate Limiting with Standard Headers

Rate limiting is mandatory for public APIs. Use standard headers:

HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 999
X-RateLimit-Reset: 1642596000

When limit exceeded:

HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1642596000
Retry-After: 3600

{
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Rate limit exceeded. Try again in 1 hour."
  }
}

Implementation with Redis:

const redis = require('redis');
const client = redis.createClient();

async function rateLimitMiddleware(req, res, next) {
  const key = `rate_limit:${req.ip}`;
  const limit = 1000;
  const window = 3600; // 1 hour

  const current = await client.incr(key);

  if (current === 1) {
    await client.expire(key, window);
  }

  const ttl = await client.ttl(key);
  const resetTime = Math.floor(Date.now() / 1000) + ttl;

  res.set('X-RateLimit-Limit', limit);
  res.set('X-RateLimit-Remaining', Math.max(0, limit - current));
  res.set('X-RateLimit-Reset', resetTime);

  if (current > limit) {
    res.set('Retry-After', ttl);
    return res.status(429).json({
      error: {
        code: 'RATE_LIMIT_EXCEEDED',
        message: `Rate limit exceeded. Try again in ${ttl} seconds.`
      }
    });
  }

  next();
}

5. Idempotency Keys for Safe Retries

Network failures happen. Idempotency keys prevent duplicate operations:

POST /api/v1/orders
Content-Type: application/json
Idempotency-Key: a1b2c3d4-e5f6-7890-abcd-ef1234567890

{
  "pet_id": 1,
  "quantity": 1
}

If the request fails and retries, the same idempotency key prevents creating duplicate orders.

Implementation:

const processedKeys = new Map(); // Use Redis in production

app.post('/api/v1/orders', async (req, res) => {
  const idempotencyKey = req.headers['idempotency-key'];

  if (!idempotencyKey) {
    return res.status(400).json({
      error: { message: 'Idempotency-Key header required' }
    });
  }

  // Check if already processed
  if (processedKeys.has(idempotencyKey)) {
    return res.status(200).json(processedKeys.get(idempotencyKey));
  }

  // Process order
  const order = await db.orders.create(req.body);

  // Store result
  processedKeys.set(idempotencyKey, order);

  res.status(201).json(order);
});

Critical for payment and order APIs.

Security Best Practices in 2026

Security threats evolved. So did defenses.

1. OAuth 2.1 and PKCE

OAuth 2.1 consolidated best practices and made PKCE mandatory for all clients.

Authorization flow:

// 1. Generate code verifier and challenge
const codeVerifier = generateRandomString(128);
const codeChallenge = base64UrlEncode(sha256(codeVerifier));

// 2. Redirect to authorization endpoint
const authUrl = `https://auth.example.com/authorize?` +
  `response_type=code&` +
  `client_id=your_client_id&` +
  `redirect_uri=https://yourapp.com/callback&` +
  `code_challenge=${codeChallenge}&` +
  `code_challenge_method=S256&` +
  `scope=read:pets write:pets`;

window.location.href = authUrl;

// 3. Exchange code for token
const tokenResponse = await fetch('https://auth.example.com/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    grant_type: 'authorization_code',
    code: authorizationCode,
    redirect_uri: 'https://yourapp.com/callback',
    client_id: 'your_client_id',
    code_verifier: codeVerifier
  })
});

const { access_token } = await tokenResponse.json();

PKCE prevents authorization code interception attacks.

2. API Keys with Scopes

API keys should have limited permissions:

{
  "key": "pk_live_abc123",
  "scopes": ["read:pets", "write:pets"],
  "rate_limit": 1000,
  "expires_at": "2026-12-31T23:59:59Z"
}

Validate scopes on each request:

function requireScope(scope) {
  return (req, res, next) => {
    const apiKey = req.headers['x-api-key'];
    const key = validateApiKey(apiKey);

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

    if (!key.scopes.includes(scope)) {
      return res.status(403).json({
        error: { message: `Missing required scope: ${scope}` }
      });
    }

    req.apiKey = key;
    next();
  };
}

app.get('/api/v1/pets', requireScope('read:pets'), async (req, res) => {
  // Handle request
});

3. Input Validation with JSON Schema

Validate all inputs against schemas:

const Ajv = require('ajv');
const ajv = new Ajv();

const petSchema = {
  type: 'object',
  required: ['name', 'status'],
  properties: {
    name: {
      type: 'string',
      minLength: 1,
      maxLength: 100
    },
    status: {
      type: 'string',
      enum: ['available', 'pending', 'sold']
    },
    category: {
      type: 'string',
      maxLength: 50
    }
  },
  additionalProperties: false
};

const validate = ajv.compile(petSchema);

app.post('/api/v1/pets', (req, res) => {
  if (!validate(req.body)) {
    return res.status(422).json({
      error: {
        code: 'VALIDATION_ERROR',
        message: 'Invalid pet data',
        details: validate.errors
      }
    });
  }

  // Process valid data
});

Never trust client input.

Performance Optimization

Fast APIs keep users happy.

1. Response Compression

Enable gzip/brotli compression:

const compression = require('compression');

app.use(compression({
  level: 6,
  threshold: 1024, // Only compress responses > 1KB
  filter: (req, res) => {
    if (req.headers['x-no-compression']) {
      return false;
    }
    return compression.filter(req, res);
  }
}));

Reduces response sizes by 70-90%.

2. ETags for Caching

ETags let clients cache responses:

app.get('/api/v1/pets/:id', async (req, res) => {
  const pet = await db.pets.findById(req.params.id);

  if (!pet) {
    return res.status(404).json({ error: { message: 'Pet not found' } });
  }

  const etag = `"${pet.id}-${pet.updated_at.getTime()}"`;

  if (req.headers['if-none-match'] === etag) {
    return res.status(304).end();
  }

  res.set('ETag', etag);
  res.set('Cache-Control', 'private, max-age=60');
  res.json(pet);
});

Clients send If-None-Match on subsequent requests. If data hasn't changed, return 304 Not Modified with no body.

3. Database Query Optimization

N+1 queries kill performance:

// Bad: N+1 queries
const pets = await db.pets.findAll();
for (const pet of pets) {
  pet.owner = await db.users.findById(pet.owner_id); // N queries
}

// Good: Single query with join
const pets = await db.pets.findAll({
  include: [{ model: db.users, as: 'owner' }]
});

Use database indexes on filtered/sorted fields:

CREATE INDEX idx_pets_status ON pets(status);
CREATE INDEX idx_pets_created_at ON pets(created_at DESC);

Documentation That Developers Actually Use

Good docs are as important as good code.

1. Interactive API Documentation

Use tools like Swagger UI or Redoc:

const swaggerUi = require('swagger-ui-express');
const YAML = require('yamljs');

const swaggerDocument = YAML.load('./openapi.yaml');

app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument, {
  customCss: '.swagger-ui .topbar { display: none }',
  customSiteTitle: 'PetStore API Documentation'
}));

Developers can test endpoints directly from docs.

2. Code Examples in Multiple Languages

Show examples in popular languages:

## Create a Pet

### cURL
```bash
curl -X POST https://api.example.com/v1/pets \
  -H "Content-Type: application/json" \
  -H "X-API-Key: your_api_key" \
  -d '{"name":"Buddy","status":"available"}'

JavaScript

const response = await fetch('https://api.example.com/v1/pets', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-API-Key': 'your_api_key'
  },
  body: JSON.stringify({ name: 'Buddy', status: 'available' })
});
const pet = await response.json();

Python

import requests

response = requests.post(
    'https://api.example.com/v1/pets',
    headers={'X-API-Key': 'your_api_key'},
    json={'name': 'Buddy', 'status': 'available'}
)
pet = response.json()
### 3. Changelog for API Changes

Document every change:

```markdown
# API Changelog

## v2.1.0 - 2026-03-01

### Added
- New `GET /api/v1/pets/:id/medical-records` endpoint
- Support for filtering pets by `breed` parameter

### Changed
- `POST /api/v1/pets` now returns `201` instead of `200`
- Increased rate limit from 1000 to 5000 requests/hour

### Deprecated
- `GET /api/v1/pets/search` - Use `GET /api/v1/pets` with query parameters instead

### Fixed
- Fixed pagination cursor encoding for pets with special characters

What to Avoid in 2026

Some patterns that seemed good turned out bad.

1. Over-engineering with microservices: Start with a monolith. Split when you have a reason.

2. Premature GraphQL adoption: REST works fine for most APIs.

3. Custom authentication: Use OAuth 2.1 or API keys. Don't roll your own.

4. Ignoring HTTP standards: Use proper methods and status codes.

5. No versioning strategy: You'll break clients eventually.

6. Skipping rate limiting: Your API will get abused.

7. Poor error messages: "Error 500" helps nobody.

8. No monitoring: You can't fix what you can't measure.

Conclusion

REST API best practices in 2026 balance new standards with timeless principles.

Adopt HTTP/3 for performance. Use OpenAPI 3.1 for documentation. Choose JSON:API or custom JSON based on your needs. Implement cursor pagination, field selection, and proper rate limiting.

But don't forget the basics: intuitive URLs, proper HTTP methods, meaningful status codes, and good documentation.

The best API is one developers enjoy using. Focus on that, and the rest follows.