Skip to content

15 Pagination Implementation

DodaTech 4 min read

title: Pagination Implementation in Node.js REST APIs weight: 25 date: 2026-06-28 lastmod: 2026-06-28 description: Implement offset and cursor-based pagination in Node.js REST APIs with Express, including pagination metadata, Link headers, and cursor encoding for consistent iteration. tags: [api-development, nodejs]


Pagination in Node.js REST APIs splits result sets into pages using offset-based (page/per_page) or cursor-based (cursor/limit) approaches, with pagination metadata and Link headers for client navigation.

```mermaid
flowchart TD
  A[Pagination] --> B[Offset-Based]
  A --> C[Cursor-Based]
  B --> D[?page=2&per_page=20]
  B --> E[Simple, skip-based]
  C --> F[?cursor=abc&limit=20]
  C --> G[Stable, key-based]
  D --> H[Total + Page Count]
  C --> I[Next Cursor + Has More]
  style A fill:#e1f5fe
  style B fill:#fff9c4
  style C fill:#c8e6c9

Offset pagination calculates skip = (page - 1) * limit. It is simple but has performance issues on large datasets (skip scans all previous documents). Cursor pagination uses a unique field to mark position. It is faster and stable when data changes.

Think of offset pagination like a book with page numbers. If pages are inserted or removed, your page reference changes. Cursor pagination is like a bookmark. You always continue from where you left off, regardless of what happens between pages.

Example: Offset Pagination Middleware

const paginate = (defaultLimit = 10, maxLimit = 100) => {
  return (req, res, next) => {
    const page = Math.max(1, parseInt(req.query.page) || 1);
    const limit = Math.min(maxLimit, Math.max(1, parseInt(req.query.limit) || defaultLimit));
    const skip = (page - 1) * limit;

    req.pagination = { page, limit, skip };
    next();
  };
};

// Usage in controller
app.get('/api/products',
  paginate(20, 50),
  async (req, res, next) => {
    try {
      const { skip, limit, page } = req.pagination;

      const [products, total] = await Promise.all([
        Product.find().skip(skip).limit(limit),
        Product.countDocuments()
      ]);

      // Build pagination response
      const totalPages = Math.ceil(total / limit);

      res.json({
        status: 'success',
        data: products,
        meta: {
          total,
          page,
          limit,
          totalPages,
          hasNext: page < totalPages,
          hasPrev: page > 1
        }
      });
    } catch (error) {
      next(error);
    }
  }
);

Example: Cursor Pagination Implementation

app.get('/api/products', async (req, res, next) => {
  try {
    const limit = Math.min(100, parseInt(req.query.limit) || 20);
    const cursor = req.query.cursor || null;

    let query = {};
    if (cursor) {
      // Decode cursor (base64 encoded {id, createdAt})
      const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString());
      query = {
        $or: [
          { createdAt: { $lt: new Date(decoded.createdAt) } },
          { createdAt: decoded.createdAt, _id: { $gt: decoded.id } }
        ]
      };
    }

    const products = await Product.find(query)
      .sort({ createdAt: -1, _id: 1 })
      .limit(limit + 1);

    const hasMore = products.length > limit;
    if (hasMore) products.pop();

    let nextCursor = null;
    if (hasMore) {
      const last = products[products.length - 1];
      const cursorData = { id: last._id, createdAt: last.createdAt.toISOString() };
      nextCursor = Buffer.from(JSON.stringify(cursorData)).toString('base64');
    }

    res.json({
      status: 'success',
      data: products,
      meta: {
        limit,
        hasMore,
        nextCursor
      }
    });
  } catch (error) {
    next(error);
  }
});

Example: Link Header Pagination

const setPaginationHeaders = (res, total, page, limit) => {
  const totalPages = Math.ceil(total / limit);
  const baseUrl = `${req.protocol}://${req.get('host')}${req.baseUrl}${req.path}`;

  const links = {};

  links.first = `${baseUrl}?page=1&limit=${limit}`;
  links.last = `${baseUrl}?page=${totalPages}&limit=${limit}`;
  if (page > 1) links.prev = `${baseUrl}?page=${page - 1}&limit=${limit}`;
  if (page < totalPages) links.next = `${baseUrl}?page=${page + 1}&limit=${limit}`;

  const linkStrings = Object.entries(links)
    .map(([rel, url]) => `<${url}>; rel="${rel}"`);

  res.setHeader('Link', linkStrings.join(', '));
  res.setHeader('X-Total-Count', total);
  res.setHeader('X-Page', page);
  res.setHeader('X-Per-Page', limit);
};

Common Mistakes

  1. Performance issues with large skip values — MongoDB skip + limit becomes slow for high page numbers. Use cursor-based pagination or range queries for large datasets.
  2. Inconsistent results with offset pagination — When items are added or deleted between requests, users may see duplicates or miss items. Cursor pagination avoids this.
  3. Not limiting maximum page size — Allowing unlimited per_page value defeats the purpose of pagination. Set a maximum (100 is common).
  4. Missing pagination metadata — Without total, page, and totalPages, clients cannot build navigation UI. Always include metadata.
  5. Cursor exposing internal IDs — Base64 encoding is not encryption. Do not include sensitive data in cursors. Use opaque tokens if security is a concern.

Practice Questions

  1. What is the performance problem with offset pagination for large datasets?
  2. How does cursor pagination maintain consistency when data changes?
  3. What metadata should a paginated response include?
  4. What is the Link header used for in pagination?
  5. Challenge: Implement both offset and cursor pagination for a blog API. Compare the performance with 10000 posts. Create a pagination utility that supports both modes with a single interface.

FAQ

When should I use cursor pagination over offset?

Use cursor for real-time data, infinite scroll, and large datasets. Use offset for simple, static lists where users need to jump to specific pages.

How do I encode cursor data?

Base64 encode a JSON object containing the last item's unique identifier (usually _id and a timestamp). Decode and parse on the server.

Should I include total count in cursor pagination?

Total count is expensive on large datasets. Many cursor APIs omit total or offer it as an opt-in parameter (include_total=true).

How do I handle pagination with filters?

Apply filters before pagination. The total count should reflect the filtered results, not the entire collection.

What is keyset pagination?

Keyset pagination uses WHERE clauses: WHERE id > 100 LIMIT 10. It is efficient but requires a unique, sortable field and does not support page numbers.

Mini Project

Build a pagination module that supports both offset and cursor-based pagination. Include middleware for parsing pagination parameters, response formatting with metadata, Link header generation, and cursor encoding/decoding. Test with both small and large datasets.

What's Next

Now learn about file upload in Building REST APIs with Node.js.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro