API Pagination: Complete Implementation Guide for Production APIs

by API Status Check Team

API Pagination: Complete Implementation Guide for Production APIs

Pagination is essential for any API that returns lists of data. Without it, your API becomes slow, expensive, and unusable as your data grows. A single unpaginated /users endpoint can bring down your database when it tries to return 10 million records.

This guide covers production-ready pagination patterns used by GitHub, Stripe, and Twitter. You'll learn which pattern to choose, how to implement them efficiently, and common pitfalls that cause performance problems.

Table of Contents

  1. Why Pagination Matters
  2. Pagination Patterns Comparison
  3. Offset-Based Pagination
  4. Cursor-Based Pagination
  5. Keyset Pagination
  6. Page Number Pagination
  7. Response Format Standards
  8. Performance Optimization
  9. Client Implementation
  10. Common Mistakes
  11. Production Checklist

Why Pagination Matters

Without pagination:

// ❌ Dangerous: Returns all users (10M records = 2GB response)
app.get('/users', async (req, res) => {
  const users = await db.users.findMany();
  res.json({ users });
});

Problems:

  • Database overload: Fetching millions of rows crashes your database
  • Memory exhaustion: Your API server runs out of RAM
  • Network timeouts: 2GB responses take minutes to transfer
  • Poor UX: Clients can't render 10M items anyway
  • Cost: Transferring huge responses burns bandwidth budget

With pagination:

// ✅ Safe: Returns 20 users per page
app.get('/users', async (req, res) => {
  const limit = Math.min(parseInt(req.query.limit) || 20, 100);
  const offset = parseInt(req.query.offset) || 0;
  
  const [users, total] = await Promise.all([
    db.users.findMany({ skip: offset, take: limit }),
    db.users.count()
  ]);
  
  res.json({
    data: users,
    pagination: {
      offset,
      limit,
      total,
      hasMore: offset + limit < total
    }
  });
});

Benefits:

  • Database protection: Only fetch what's needed
  • Fast responses: 20KB instead of 2GB
  • Better UX: Clients load data progressively
  • Cost savings: Reduced bandwidth and compute

Pagination Patterns Comparison

Pattern Best For Performance Consistency Complexity
Offset/Limit Simple lists, admin panels Poor at high offsets Inconsistent (skips/duplicates) Low
Cursor-Based Infinite scroll, real-time feeds Excellent Consistent Medium
Keyset Large datasets, sorted results Excellent Consistent Medium
Page Numbers User-facing pagination with page jumps Poor at high pages Inconsistent Low

When to Use Each Pattern

Use Offset/Limit when:

  • Dataset is small (<10K records)
  • Admin panels where performance isn't critical
  • Internal tools with controlled usage
  • Example: Internal dashboard showing recent orders

Use Cursor-Based when:

  • Infinite scroll interfaces (Twitter, Facebook feeds)
  • Real-time data streams
  • Large datasets with frequent changes
  • Example: Twitter API, Slack API

Use Keyset when:

  • Large datasets (millions of rows)
  • Results are sorted by indexed column (created_at, id)
  • You need consistent performance
  • Example: GitHub API, Stripe API

Use Page Numbers when:

  • Traditional pagination UI (1, 2, 3... buttons)
  • Small-medium datasets (<100K records)
  • Users need to jump to specific pages
  • Example: Search results, product catalogs

Offset-Based Pagination

The simplest pattern. Uses limit (page size) and offset (how many to skip).

Request Format

GET /users?limit=20&offset=40
  • limit=20: Return 20 results
  • offset=40: Skip first 40 results (page 3)

Implementation

import { Request, Response } from 'express';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

interface PaginationQuery {
  limit?: string;
  offset?: string;
}

app.get('/users', async (req: Request<{}, {}, {}, PaginationQuery>, res: Response) => {
  // Parse and validate pagination params
  const limit = Math.min(parseInt(req.query.limit || '20'), 100); // Max 100
  const offset = Math.max(parseInt(req.query.offset || '0'), 0);  // Min 0
  
  try {
    // Parallel queries for data and count
    const [users, total] = await Promise.all([
      prisma.user.findMany({
        skip: offset,
        take: limit,
        select: {
          id: true,
          name: true,
          email: true,
          createdAt: true
        },
        orderBy: { createdAt: 'desc' }
      }),
      prisma.user.count()
    ]);
    
    res.json({
      data: users,
      pagination: {
        offset,
        limit,
        total,
        hasMore: offset + limit < total
      }
    });
  } catch (error) {
    res.status(500).json({ error: 'Failed to fetch users' });
  }
});

SQL Generated

-- Data query
SELECT id, name, email, created_at
FROM users
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;

-- Count query
SELECT COUNT(*) FROM users;

Pros

  • Simple to implement: Two query parameters
  • Intuitive: Easy to understand (skip 40, take 20)
  • Jump to any page: Can calculate offset for page N
  • Total count available: Know exactly how many results exist

Cons

  • Poor performance at high offsets: OFFSET 1000000 is slow
    • Database must scan and discard 1M rows
    • Linear time complexity: O(offset + limit)
  • Inconsistent results: Duplicates or skipped items if data changes
    • User deletes item #5 → all subsequent items shift
    • Page 1: items 1-20, then item #10 gets deleted
    • Page 2: Expected items 21-40, actually gets items 20-39 (duplicate item #20)
  • Expensive count queries: COUNT(*) on large tables is slow
  • Not recommended for >100K records

Real-World Example: Admin Panel

// Admin panel pagination with offset/limit
app.get('/admin/orders', requireAdmin, async (req, res) => {
  const page = parseInt(req.query.page || '1');
  const limit = 50; // Fixed page size
  const offset = (page - 1) * limit;
  
  const [orders, total] = await Promise.all([
    prisma.order.findMany({
      skip: offset,
      take: limit,
      include: { user: true, items: true },
      orderBy: { createdAt: 'desc' }
    }),
    prisma.order.count()
  ]);
  
  const totalPages = Math.ceil(total / limit);
  
  res.json({
    data: orders,
    pagination: {
      page,
      limit,
      total,
      totalPages,
      hasNext: page < totalPages,
      hasPrev: page > 1
    }
  });
});

Cursor-Based Pagination

Uses an opaque cursor (pointer) to mark position in the result set. Most scalable pattern for large datasets.

Request Format

# First page
GET /users?limit=20

# Next page
GET /users?limit=20&cursor=eyJpZCI6MTIzNH0=

# Previous page (if supported)
GET /users?limit=20&cursor=eyJpZCI6MTIzNH0=&direction=prev

Implementation

interface CursorPaginationQuery {
  limit?: string;
  cursor?: string; // Base64-encoded JSON: {"id": 1234, "createdAt": "2026-03-10T..."}
  direction?: 'next' | 'prev';
}

// Encode cursor (id + sort field)
function encodeCursor(id: number, createdAt: Date): string {
  return Buffer.from(JSON.stringify({ id, createdAt })).toString('base64');
}

// Decode cursor
function decodeCursor(cursor: string): { id: number; createdAt: string } | null {
  try {
    return JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8'));
  } catch {
    return null;
  }
}

app.get('/users', async (req: Request<{}, {}, {}, CursorPaginationQuery>, res: Response) => {
  const limit = Math.min(parseInt(req.query.limit || '20'), 100);
  const cursorData = req.query.cursor ? decodeCursor(req.query.cursor) : null;
  const direction = req.query.direction || 'next';
  
  // Build WHERE clause based on cursor
  const where = cursorData ? {
    OR: [
      // If sorting by createdAt DESC, get items BEFORE the cursor
      { createdAt: { lt: new Date(cursorData.createdAt) } },
      // Handle items with same createdAt (use id as tiebreaker)
      {
        createdAt: new Date(cursorData.createdAt),
        id: { lt: cursorData.id }
      }
    ]
  } : {};
  
  const users = await prisma.user.findMany({
    where,
    take: limit + 1, // Fetch one extra to check if hasMore
    orderBy: [
      { createdAt: 'desc' },
      { id: 'desc' } // Tiebreaker for identical timestamps
    ],
    select: {
      id: true,
      name: true,
      email: true,
      createdAt: true
    }
  });
  
  const hasMore = users.length > limit;
  const data = hasMore ? users.slice(0, -1) : users;
  
  // Generate next cursor from last item
  const nextCursor = hasMore && data.length > 0
    ? encodeCursor(data[data.length - 1].id, data[data.length - 1].createdAt)
    : null;
  
  res.json({
    data,
    pagination: {
      nextCursor,
      hasMore,
      limit
    }
  });
});

SQL Generated

-- First page (no cursor)
SELECT id, name, email, created_at
FROM users
ORDER BY created_at DESC, id DESC
LIMIT 21;

-- Next page (with cursor for id=1234, createdAt='2026-03-10 14:30:00')
SELECT id, name, email, created_at
FROM users
WHERE created_at < '2026-03-10 14:30:00'
   OR (created_at = '2026-03-10 14:30:00' AND id < 1234)
ORDER BY created_at DESC, id DESC
LIMIT 21;

Pros

  • Excellent performance: Uses indexed WHERE clause (no OFFSET scan)
  • Consistent results: Immune to deletions/insertions
    • Cursor points to exact position, doesn't shift
  • No count query needed: More efficient
  • Scales to millions of rows: Constant time complexity O(limit)

Cons

  • Can't jump to arbitrary page: Only next/prev navigation
  • No total count: Don't know how many results exist
  • More complex to implement: Encoding/decoding cursors
  • Cursor format must be stable: Schema changes break old cursors

Real-World Example: Twitter-Style Feed

// Infinite scroll feed pagination
app.get('/posts', async (req, res) => {
  const limit = 20;
  const cursor = req.query.cursor ? decodeCursor(req.query.cursor as string) : null;
  
  const posts = await prisma.post.findMany({
    where: cursor ? {
      OR: [
        { createdAt: { lt: new Date(cursor.createdAt) } },
        {
          createdAt: new Date(cursor.createdAt),
          id: { lt: cursor.id }
        }
      ]
    } : {},
    take: limit + 1,
    orderBy: [
      { createdAt: 'desc' },
      { id: 'desc' }
    ],
    include: {
      author: { select: { id: true, name: true, avatar: true } },
      _count: { select: { likes: true, comments: true } }
    }
  });
  
  const hasMore = posts.length > limit;
  const data = hasMore ? posts.slice(0, -1) : posts;
  const nextCursor = hasMore && data.length > 0
    ? encodeCursor(data[data.length - 1].id, data[data.length - 1].createdAt)
    : null;
  
  res.json({ data, nextCursor, hasMore });
});

Keyset Pagination

Similar to cursor-based, but uses the actual sort key (e.g., id, createdAt) instead of an opaque cursor. More transparent but less flexible.

Request Format

# First page
GET /users?limit=20&sort=createdAt:desc

# Next page (last item had id=1234, createdAt=2026-03-10T14:30:00Z)
GET /users?limit=20&sort=createdAt:desc&after_id=1234&after_created_at=2026-03-10T14:30:00Z

Implementation

interface KeysetPaginationQuery {
  limit?: string;
  after_id?: string;
  after_created_at?: string;
  sort?: 'createdAt:asc' | 'createdAt:desc';
}

app.get('/users', async (req: Request<{}, {}, {}, KeysetPaginationQuery>, res: Response) => {
  const limit = Math.min(parseInt(req.query.limit || '20'), 100);
  const afterId = req.query.after_id ? parseInt(req.query.after_id) : null;
  const afterCreatedAt = req.query.after_created_at;
  const sortDir = req.query.sort === 'createdAt:asc' ? 'asc' : 'desc';
  
  const where = afterId && afterCreatedAt ? {
    OR: [
      { createdAt: sortDir === 'desc' 
          ? { lt: new Date(afterCreatedAt) }
          : { gt: new Date(afterCreatedAt) }
      },
      {
        createdAt: new Date(afterCreatedAt),
        id: sortDir === 'desc' ? { lt: afterId } : { gt: afterId }
      }
    ]
  } : {};
  
  const users = await prisma.user.findMany({
    where,
    take: limit + 1,
    orderBy: [
      { createdAt: sortDir },
      { id: sortDir }
    ]
  });
  
  const hasMore = users.length > limit;
  const data = hasMore ? users.slice(0, -1) : users;
  
  res.json({
    data,
    pagination: {
      hasMore,
      nextKeys: hasMore && data.length > 0 ? {
        after_id: data[data.length - 1].id,
        after_created_at: data[data.length - 1].createdAt.toISOString()
      } : null
    }
  });
});

Pros

  • Same performance as cursor-based: Indexed WHERE clause
  • Transparent keys: Easier to debug (human-readable)
  • Consistent results: Immune to shifts
  • RESTful: Keys are part of resource representation

Cons

  • Exposes internal schema: Clients know about id and createdAt fields
  • Less flexible: Can't easily change sort logic without breaking clients
  • Multiple query params: More verbose than single cursor

Real-World Example: GitHub API

GitHub's REST API uses keyset pagination with since parameter:

# Get users created after id=1000
GET https://api.github.com/users?since=1000&per_page=30

Response includes Link header for next page:

Link: <https://api.github.com/users?since=1030&per_page=30>; rel="next"

Page Number Pagination

Traditional pagination with page numbers (1, 2, 3...). Simple for users but has performance issues at high page numbers.

Request Format

GET /products?page=1&per_page=20
GET /products?page=5&per_page=20

Implementation

interface PageNumberQuery {
  page?: string;
  per_page?: string;
}

app.get('/products', async (req: Request<{}, {}, {}, PageNumberQuery>, res: Response) => {
  const page = Math.max(parseInt(req.query.page || '1'), 1);
  const perPage = Math.min(parseInt(req.query.per_page || '20'), 100);
  const offset = (page - 1) * perPage;
  
  const [products, total] = await Promise.all([
    prisma.product.findMany({
      skip: offset,
      take: perPage,
      where: { active: true },
      orderBy: { name: 'asc' }
    }),
    prisma.product.count({ where: { active: true } })
  ]);
  
  const totalPages = Math.ceil(total / perPage);
  
  res.json({
    data: products,
    pagination: {
      page,
      perPage,
      total,
      totalPages,
      hasNext: page < totalPages,
      hasPrev: page > 1
    }
  });
});

Pros

  • User-friendly: Everyone understands page numbers
  • Jump to any page: Direct navigation to page 5
  • Total pages known: Show "Page 5 of 42"
  • Simple implementation: Just math on offset

Cons

  • Same performance issues as offset: Slow at high pages
  • Inconsistent results: Items shift between pages
  • Requires total count: Expensive on large tables

Response Format Standards

Minimal Format

{
  "data": [...],
  "nextCursor": "eyJpZCI6MTIzNH0="
}

Standard Format

{
  "data": [...],
  "pagination": {
    "limit": 20,
    "offset": 40,
    "total": 1500,
    "hasMore": true
  }
}

GitHub-Style Link Headers

Link: <https://api.example.com/users?page=3>; rel="next",
      <https://api.example.com/users?page=1>; rel="first",
      <https://api.example.com/users?page=10>; rel="last"
// Generate Link header
function buildLinkHeader(baseUrl: string, page: number, totalPages: number): string {
  const links: string[] = [];
  
  if (page < totalPages) {
    links.push(`<${baseUrl}?page=${page + 1}>; rel="next"`);
  }
  if (page > 1) {
    links.push(`<${baseUrl}?page=${page - 1}>; rel="prev"`);
  }
  links.push(`<${baseUrl}?page=1>; rel="first"`);
  links.push(`<${baseUrl}?page=${totalPages}>; rel="last"`);
  
  return links.join(', ');
}

app.get('/users', async (req, res) => {
  // ... pagination logic ...
  
  res.set('Link', buildLinkHeader('/users', page, totalPages));
  res.json({ data: users });
});

Stripe-Style Response

Stripe API includes both data and pagination metadata:

{
  "object": "list",
  "data": [...],
  "has_more": true,
  "url": "/v1/customers",
  "next_page": "starting_after=cus_123"
}

Performance Optimization

1. Index Your Sort Columns

-- Composite index for cursor-based pagination
CREATE INDEX idx_users_created_id ON users(created_at DESC, id DESC);

-- Query uses index efficiently
SELECT * FROM users
WHERE created_at < '2026-03-10 14:30:00'
   OR (created_at = '2026-03-10 14:30:00' AND id < 1234)
ORDER BY created_at DESC, id DESC
LIMIT 21;

Why it matters:

  • Without index: Full table scan (slow)
  • With index: Index seek (fast)
  • 1000x faster on large tables

2. Avoid COUNT(*) When Possible

// ❌ Slow: Counts all rows every time
const total = await prisma.user.count();

// ✅ Fast: Use cursor pagination (no count needed)
const hasMore = users.length > limit;

// ✅ Alternative: Cache count, update on writes
const cachedTotal = await redis.get('users:count');

Why COUNT(*) is slow:

  • Must scan entire table (or index)
  • Not needed for infinite scroll UIs
  • Becomes a bottleneck at 10M+ rows

3. Parallel Queries

// ❌ Sequential: 200ms + 150ms = 350ms total
const users = await prisma.user.findMany({ skip: offset, take: limit });
const total = await prisma.user.count();

// ✅ Parallel: max(200ms, 150ms) = 200ms total
const [users, total] = await Promise.all([
  prisma.user.findMany({ skip: offset, take: limit }),
  prisma.user.count()
]);

4. Limit Maximum Page Size

// ❌ Dangerous: Client can request 1M records
const limit = parseInt(req.query.limit || '20');

// ✅ Safe: Cap at 100 records
const limit = Math.min(parseInt(req.query.limit || '20'), 100);

5. Use Database-Level Pagination

// ✅ Database does pagination (efficient)
const users = await prisma.user.findMany({ skip: offset, take: limit });

// ❌ Application does pagination (loads everything into memory)
const allUsers = await prisma.user.findMany();
const users = allUsers.slice(offset, offset + limit);

6. Denormalize Counts

For frequently accessed counts, denormalize into a separate table:

-- Denormalized counts table
CREATE TABLE entity_counts (
  entity_type VARCHAR(50) PRIMARY KEY,
  count BIGINT NOT NULL,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Update on writes (trigger or application logic)
INSERT INTO entity_counts (entity_type, count, updated_at)
VALUES ('users', (SELECT COUNT(*) FROM users), NOW())
ON CONFLICT (entity_type) DO UPDATE SET
  count = EXCLUDED.count,
  updated_at = NOW();

Then query is instant:

const total = await prisma.entityCounts.findUnique({
  where: { entityType: 'users' }
}).then(row => row?.count || 0);

Client Implementation

JavaScript/TypeScript Client

// Cursor-based pagination client
async function fetchAllUsers(): Promise<User[]> {
  const allUsers: User[] = [];
  let cursor: string | null = null;
  
  while (true) {
    const params = new URLSearchParams({ limit: '100' });
    if (cursor) params.set('cursor', cursor);
    
    const response = await fetch(`/api/users?${params}`);
    const { data, pagination } = await response.json();
    
    allUsers.push(...data);
    
    if (!pagination.hasMore) break;
    cursor = pagination.nextCursor;
    
    // Rate limiting: Don't hammer the API
    await new Promise(resolve => setTimeout(resolve, 100));
  }
  
  return allUsers;
}

React Infinite Scroll Hook

import { useState, useEffect } from 'react';

interface UsePaginationResult<T> {
  data: T[];
  loading: boolean;
  error: Error | null;
  loadMore: () => void;
  hasMore: boolean;
}

function useCursorPagination<T>(url: string): UsePaginationResult<T> {
  const [data, setData] = useState<T[]>([]);
  const [cursor, setCursor] = useState<string | null>(null);
  const [hasMore, setHasMore] = useState(true);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  
  const loadMore = async () => {
    if (loading || !hasMore) return;
    
    setLoading(true);
    setError(null);
    
    try {
      const params = new URLSearchParams({ limit: '20' });
      if (cursor) params.set('cursor', cursor);
      
      const response = await fetch(`${url}?${params}`);
      const result = await response.json();
      
      setData(prev => [...prev, ...result.data]);
      setCursor(result.pagination.nextCursor);
      setHasMore(result.pagination.hasMore);
    } catch (err) {
      setError(err instanceof Error ? err : new Error('Failed to load'));
    } finally {
      setLoading(false);
    }
  };
  
  // Load first page on mount
  useEffect(() => {
    loadMore();
  }, []);
  
  return { data, loading, error, loadMore, hasMore };
}

// Usage
function UserList() {
  const { data, loading, loadMore, hasMore } = useCursorPagination<User>('/api/users');
  
  return (
    <div>
      {data.map(user => <UserCard key={user.id} user={user} />)}
      {hasMore && (
        <button onClick={loadMore} disabled={loading}>
          {loading ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  );
}

Common Mistakes

1. Using OFFSET for Large Datasets

// ❌ Slow: Scanning 1M rows to skip them
SELECT * FROM users
ORDER BY created_at DESC
LIMIT 20 OFFSET 1000000;
-- Takes 5+ seconds on 10M row table
// ✅ Fast: Using WHERE clause with index
SELECT * FROM users
WHERE created_at < '2026-01-01 00:00:00'
ORDER BY created_at DESC
LIMIT 20;
-- Takes <50ms on 10M row table

2. Not Handling Empty Results

// ❌ Crashes on empty results
const nextCursor = encodeCursor(
  data[data.length - 1].id,  // Throws if data is empty
  data[data.length - 1].createdAt
);

// ✅ Safe: Check before accessing
const nextCursor = data.length > 0 && hasMore
  ? encodeCursor(data[data.length - 1].id, data[data.length - 1].createdAt)
  : null;

3. Inconsistent Sort Order

// ❌ Inconsistent: Items with same createdAt in random order
ORDER BY created_at DESC

// ✅ Consistent: Tiebreaker using unique id
ORDER BY created_at DESC, id DESC

Why it matters: Without a tiebreaker, items with identical created_at values return in random order, causing duplicates/skips.

4. Not Validating Pagination Parameters

// ❌ Dangerous: Allows negative offsets, huge limits
const offset = parseInt(req.query.offset);
const limit = parseInt(req.query.limit);

// ✅ Safe: Validate and clamp
const offset = Math.max(parseInt(req.query.offset || '0'), 0);
const limit = Math.min(Math.max(parseInt(req.query.limit || '20'), 1), 100);

5. Returning Total Count on Every Request

// ❌ Expensive: COUNT(*) on every request
const [data, total] = await Promise.all([
  prisma.user.findMany({ skip: offset, take: limit }),
  prisma.user.count() // Slow on 10M+ rows
]);

// ✅ Efficient: Only return count on first page
const total = offset === 0 ? await prisma.user.count() : undefined;

6. Exposing Raw Database IDs in Cursors

// ❌ Exposes internal IDs
const cursor = `${user.id}`;

// ✅ Opaque cursor (Base64-encoded)
const cursor = Buffer.from(JSON.stringify({ id: user.id })).toString('base64');

7. Not Testing with Real Data Volumes

// ❌ Only test with 100 rows
// Works fine in development

// ✅ Test with production-like data
// Seed 10M rows, test offset=1000000
// Discover performance issues before users do

Production Checklist

Database:

  • Composite index on sort columns (e.g., created_at, id)
  • Verify EXPLAIN ANALYZE shows index usage (not seq scan)
  • Test performance with production data volume (10M+ rows)
  • Monitor slow query logs for pagination queries

API Design:

  • Choose pagination pattern based on use case
  • Set maximum page size (recommend 100)
  • Validate and sanitize all pagination parameters
  • Return consistent response format
  • Include hasMore or nextCursor in response
  • Document pagination in API reference

Performance:

  • Avoid COUNT(*) unless necessary (use cursor pagination)
  • Parallel queries for data + count (if count needed)
  • Cache total counts for frequently accessed endpoints
  • Set database query timeout (prevent runaway queries)
  • Monitor P95/P99 latency for pagination endpoints

Error Handling:

  • Handle invalid cursor gracefully (return 400)
  • Handle negative offsets/limits (clamp to valid range)
  • Return empty array for out-of-range pages (not 404)
  • Include error details in response

Testing:

  • Test first page, middle page, last page
  • Test empty results (no data)
  • Test edge cases (limit=0, offset > total)
  • Load test with realistic traffic patterns
  • Test with concurrent writes (verify consistency)

Client Experience:

  • Provide example client code in documentation
  • Include pagination metadata in responses
  • Use Link headers for discoverability (optional)
  • Consistent field naming (limit vs per_page)

Real-World Examples

GitHub API (Keyset Pagination)

GitHub uses since parameter for user listing:

curl https://api.github.com/users?since=1000&per_page=30

Response includes Link header:

Link: <https://api.github.com/users?since=1030&per_page=30>; rel="next",
      <https://api.github.com/users?since=0&per_page=30>; rel="first"

Stripe API (Cursor-Based)

Stripe uses starting_after cursor:

curl https://api.stripe.com/v1/customers?limit=10&starting_after=cus_123

Response:

{
  "object": "list",
  "data": [...],
  "has_more": true,
  "url": "/v1/customers"
}

Twitter API (Cursor-Based)

Twitter API uses pagination_token:

curl "https://api.twitter.com/2/tweets/search/recent?query=api&max_results=10&pagination_token=b26v89c19zqg8o3fosga3t90"

Slack API (Cursor-Based)

Slack uses cursor for conversations:

curl "https://slack.com/api/conversations.history?channel=C1234&cursor=dXNlcjpVMEc5V0ZYTlo="

Related API Patterns

Conclusion

Pagination is essential for production APIs. Choose the right pattern for your use case:

  • Small datasets (<10K): Offset/limit or page numbers
  • Large datasets (>100K): Cursor-based or keyset
  • Infinite scroll UIs: Cursor-based
  • Traditional pagination UI: Page numbers or keyset

Always index your sort columns, validate pagination parameters, and test with production data volumes. Your database (and your users) will thank you.


Monitor your APIs with API Status Check. Track uptime and outages for 160+ third-party services including GitHub, Stripe, AWS, Cloudflare, and Datadog.

API Status Check

Stop checking API status pages manually

Get instant email alerts when OpenAI, Stripe, AWS, and 100+ APIs go down. Know before your users do.

Get Alerts — $9/mo →

Free dashboard available · 14-day trial on paid plans · Cancel anytime

Browse Free Dashboard →