Build a MongoDB REST API with Cursor Pagination
A complete MongoDB REST API: schema validation, indexed queries, cursor pagination, optimistic updates, and integration tests against a real database.
Project goal
Expose CRUD endpoints backed by MongoDB with strict schema validation, stable cursor pagination, and safe partial updates.
Final features
- Repository layer
- Schema validation
- Cursor pagination
- Index creation
- Optimistic update
Architecture
An Express router maps endpoints to repository functions. A JSON schema validator guards every write. Reads use a compound index and paginate with a cursor of _id rather than a numeric offset so results stay stable under inserts.
Prerequisites
- Node.js 20 LTS or later
- A reachable MongoDB 7+ instance
- Basic knowledge of MongoDB indexes
Dependencies
- mongodb ^6.8.0 — Official Node driver
- express ^4.21.0 — HTTP routing
- ajv ^8.17.1 — JSON schema validation for writes
Directory structure
mongodb-rest-api/
├── src/router.ts
├── src/repository.ts
├── src/validate.ts
├── src/pagination.ts
├── tests/api.test.ts
└── package.json
Phases
1. Repository layer
Wrap collection access behind functions so routes stay framework-thin.
export const findByCursor = (db, { after, limit }) =>
db.collection('items').find(after ? { _id: { $gt: after } } : {}).sort({ _id: 1 }).limit(limit + 1).toArray();
Expected behavior: A repository boundary keeps query logic testable in isolation.
2. Schema validation
Reject writes that violate the JSON schema before touching the database.
const valid = ajv.validate(schema, body);
if (!valid) return res.status(400).json({ errors: ajv.errors });
Expected behavior: Validation at the boundary produces a clear error instead of a partial document.
3. Cursor pagination
Return the last _id as the next cursor and fetch limit+1 to detect more.
const next = rows.length > limit ? rows[limit - 1]._id : null;
Expected behavior: Cursor paging avoids the duplicates and skips that offset paging causes under writes.
4. Index creation
Create the compound index the queries depend on at startup.
await db.collection('items').createIndex({ _id: 1, createdAt: -1 });
Expected behavior: Without the index, pagination degrades to a full collection scan.
5. Optimistic update
Accept an If-Match version and reject a stale update with 409.
const updated = await db.collection('items').findOneAndUpdate(
{ _id, version }, { $set: patch, $inc: { version: 1 } }, { returnDocument: 'after' });
if (!updated) return res.status(409).json({ error: 'stale_version' });
Expected behavior: Concurrent edits are detected instead of silently overwritten.
Testing
Run against a disposable database per test file. Assert pagination stability by inserting documents between page fetches, and assert that a stale If-Match returns 409.
Troubleshooting
Pagination skips documents
The sort field and cursor type disagreed, so the comparison filter was wrong; keep both _id-typed.
Operator injection
Passing req.body directly as a filter lets clients supply $where; build filters explicitly.
Deployment
Require a replica set for transactions and point the driver at it explicitly. Set retryWrites, cap the connection pool, and monitor slow queries with the database profiler.
Known limitations
- The example has no aggregation or full-text search.
- Sharding is not configured.
- Schema validation is per-collection and not versioned.
Project overview
A document store makes it easy to accept any shape and hard to keep the data trustworthy. This build pairs a strict schema validator with a repository layer so writes are validated once and reads are stable.
You will expose CRUD endpoints through a repository, validate every write against a schema, paginate with a cursor over the document id rather than an offset, and reject a stale update with a conflict.
Offset pagination is the quiet bug in list endpoints: insert a document while a client pages and the next page repeats or skips a row. A cursor over a stable key removes that class of bug entirely.
The repository layer is where tenancy, validation, and projection live, so endpoints stay thin and the same rules apply whether a document is read through a list or fetched by id.
Each phase adds one property: a repository, validation, cursor pagination, an index, and optimistic update, so the API is correct at every stage.
The project suits teams that chose a document store for flexibility and later needed a contract. It keeps flexibility at the storage layer while imposing one at the API boundary, so callers get predictable shapes and the database keeps its ability to evolve.
It is aimed at teams that have debugged a page which skipped a document after a busy insert, and it fixes the cause rather than the symptom by making the cursor a position rather than an offset.
What you will build
The API provides five capabilities, each with a test against a disposable database:
- Repository layer
- Schema validation
- Cursor pagination
- Index creation
- Optimistic update
A repository centralizes access rules; schema validation rejects malformed writes at the boundary; cursor pagination keeps pages stable under inserts; a compound index makes the cursor cheap; and an optimistic update turns a lost update into a conflict.
The design treats the document store as a dependency to be constrained rather than trusted, because a schemaless database will accept whatever a buggy caller sends.
Architecture and data model
An Express router maps endpoints to repository functions. A JSON schema validator guards every write. Reads use a compound index and paginate with a cursor of _id rather than a numeric offset so results stay stable under inserts.
An Express router maps endpoints to repository functions, and the repository is the only module that touches the collection. This boundary is what makes the access rules testable without HTTP.
A JSON schema validator guards every write, rejecting extra fields and wrong types before they reach the store. Validation at the boundary is cheaper than repair after the fact.
Reads paginate with a cursor built from the sort key and the document id, so a page boundary does not move when a new document is inserted. The cursor encodes a position rather than a count.
An optimistic update uses a version or a match condition, so a write based on a stale read is rejected with a conflict rather than silently overwriting a newer value.
The repository returns plain objects shaped by a projection rather than raw documents, so an endpoint cannot leak a field added to the stored schema later. That boundary is what keeps a schema change from silently expanding every response.
Prerequisites and dependencies
You need a MongoDB deployment and a test strategy that gives each test file its own database.
- Node.js 20 LTS or later
- A reachable MongoDB 7+ instance
- Basic knowledge of MongoDB indexes
The dependencies cover the driver, the HTTP layer, and the schema validation:
mongodb(^6.8.0) — Official Node driverexpress(^4.21.0) — HTTP routingajv(^8.17.1) — JSON schema validation for writes
pnpm init
pnpm add mongodb express ajv
pnpm add -D typescript tsx vitest
Pin the write concern and the read preference in configuration, because a read from a secondary can return a stale document and make an optimistic update behave unpredictably. Record the index definitions alongside the schema.
Directory structure
Separate the router, the repository, the schema definitions, and the cursor helpers so each can be tested against a disposable database:
mongodb-rest-api/
├── src/router.ts
├── src/repository.ts
├── src/validate.ts
├── src/pagination.ts
├── tests/api.test.ts
└── package.json
Phase 1: Repository layer
Wrap collection access behind functions so routes stay framework-thin.
export const findByCursor = (db, { after, limit }) =>
db.collection('items').find(after ? { _id: { $gt: after } } : {}).sort({ _id: 1 }).limit(limit + 1).toArray();
A repository boundary keeps query logic testable in isolation.
Put all collection access behind a repository so rules such as projection and tenancy live in one place, and endpoints never build a query by hand.
Phase 2: Schema validation
Reject writes that violate the JSON schema before touching the database.
const valid = ajv.validate(schema, body);
if (!valid) return res.status(400).json({ errors: ajv.errors });
Validation at the boundary produces a clear error instead of a partial document.
Validate every write against a JSON schema and reject unknown fields, because a schemaless store will otherwise accumulate documents that no code can read.
Phase 3: Cursor pagination
Return the last _id as the next cursor and fetch limit+1 to detect more.
const next = rows.length > limit ? rows[limit - 1]._id : null;
Cursor paging avoids the duplicates and skips that offset paging causes under writes.
Paginate with a cursor over the sort key plus the id, so pages stay stable under concurrent inserts and a client can resume from the last id.
Phase 4: Index creation
Create the compound index the queries depend on at startup.
await db.collection('items').createIndex({ _id: 1, createdAt: -1 });
Without the index, pagination degrades to a full collection scan.
Create the index the cursor and the common filters depend on, and confirm with an explain that the query uses it rather than collecting the full scan.
Phase 5: Optimistic update
Accept an If-Match version and reject a stale update with 409.
const updated = await db.collection('items').findOneAndUpdate(
{ _id, version }, { $set: patch, $inc: { version: 1 } }, { returnDocument: 'after' });
if (!updated) return res.status(409).json({ error: 'stale_version' });
Concurrent edits are detected instead of silently overwritten.
Update with a match condition that includes the version or the previous value, so a stale write returns a conflict instead of overwriting a newer change.
Testing
Run against a disposable database per test file. Assert pagination stability by inserting documents between page fetches, and assert that a stale If-Match returns 409.
Run each test file against its own database so tests are independent and a failure does not cascade through ordering.
Insert documents between page fetches and assert that pagination neither repeats nor skips, which is the property offsets fail and cursors preserve.
Assert that a stale update returns a conflict rather than succeeding, which is the optimistic-update guarantee as a test.
Assert that an invalid document is rejected with a useful error, because a validator that accepts extra fields quietly allows schema drift.
Because pagination bugs depend on concurrent writes, the test must insert between page fetches rather than merely page through a static set; a static test passes for offsets too, which is exactly why it misses the bug a cursor prevents.
Assert too that an update with a stale version returns a conflict rather than overwriting, by reading a document, updating it twice, and replaying the first write.
Assert the validator’s rejection of an unknown field explicitly, because accepting extra fields is the most common way a schema quietly stops meaning anything.
pnpm vitest run
Deployment and operations
Require a replica set for transactions and point the driver at it explicitly. Set retryWrites, cap the connection pool, and monitor slow queries with the database profiler.
Require a replica set where transactions are needed, and point the driver at it explicitly rather than relying on a default that may be standalone.
Enable retryable writes so a transient network error does not turn into a failed request, and cap the connection pool to protect the database from a traffic spike.
Monitor slow queries with the database profiler and the index usage statistics, because a missing index shows up as a slow query long before it shows up as an error.
Keep index creation in the deploy path and build them in the background, so adding an index does not block writes on a large collection.
Build indexes in the background during a deploy, and watch the build as a job in its own right, because a failed background build leaves queries running without the index while the service looks otherwise healthy.
Keep a per-collection index inventory and compare it against query patterns, because an unused index costs writes and a missing index costs reads, and both are invisible without the inventory.
Security considerations
Never interpolate user input into query objects; build filters from allowlisted fields to prevent operator injection. Require auth on every route and scope reads to the caller tenant.
Validate input at the schema boundary and never accept a client-supplied owner field, because a document store will happily persist a forged owner.
Project out sensitive fields in the repository rather than in each endpoint, so a new endpoint cannot accidentally return a field it should not.
Authorize each document against the caller, not merely against the endpoint, since ownership is a property of the document rather than of the route.
Bound the result size and the query complexity, because an unbounded find on a large collection is a denial-of-service vector.
Project sensitive fields out at the repository layer and never rely on the endpoint to omit them, because a later endpoint or an ad hoc query will not know which fields are sensitive and will return them.
Reject a filter that names an operator key taken from user input, since a store that accepts query operators lets a caller reshape a query it was never meant to expose.
Observability
Log the operation, collection, cursor, result count, and duration. Track slow-query warnings and alert when the p95 read time exceeds the index baseline.
Track query duration and documents scanned per query, because the ratio of returned to scanned documents is the clearest sign of a missing index.
Chart the connection pool usage, since a pool near its ceiling makes every request slower even when the database is healthy.
Log slow queries with their shape rather than the data, so a pattern can be identified without recording user content.
Alert on write conflicts, because a rising conflict rate indicates contention on a document that may need a different model.
Track the ratio of documents returned to documents examined, because that single number reveals a missing index more reliably than latency does and without reproducing the query by hand.
Chart the write rate against the index count, because a collection with many indexes and a high write rate pays a cost that one query may not be worth.
Troubleshooting
Pagination skips documents. The sort field and cursor type disagreed, so the comparison filter was wrong; keep both _id-typed.
Operator injection. Passing req.body directly as a filter lets clients supply $where; build filters explicitly.
If a list endpoint is slow, check the index and the documents scanned first, because an unindexed sort is the usual cause and is easy to confirm with an explain.
If updates intermittently fail, check the match condition and the read preference, since reading from a secondary can hand the client a version that is already stale.
If a delete removes the wrong document, check the match condition for a missing owner field, and if a write succeeds without a conflict, check that the version is actually part of the condition rather than merely returned in the response.
If a page is empty before the collection is exhausted, check the cursor encoding against the sort key, because a mismatch drops the remaining results.
Limitations and next steps
- The example has no aggregation or full-text search.
- Sharding is not configured.
- Schema validation is per-collection and not versioned.
The API exposes CRUD over one collection and does not model aggregation pipelines or multi-collection joins, which keeps the repository simple but limits reporting queries.
Cursor pagination supports the sort order the index is built for; a client-chosen sort would require additional indexes and is not exposed.
There is no schema migration tooling for existing documents, so a schema change requires a backfill written separately.
Transactions are used only where a replica set is available, and the example does not demonstrate multi-document atomic operations in detail.
The API does not expose aggregation or text search, so reporting and full-text queries need a different path, and it assumes a single node or replica set rather than a sharded cluster, which would change how cursors and indexes are chosen.
There is no soft-delete or history model, so a delete is final unless the caller implements versioning on top of the repository.