Getting Started with CouchDB

CouchDBDatabasesNoSQL

Most databases treat replication as an afterthought, something you bolt on with third-party tools or manage through finicky leader-follower setups. CouchDB was built with a different assumption: data needs to move between nodes, devices, and environments reliably, and the database itself should handle that.

CouchDB is a document database where every operation is a plain HTTP request. It stores JSON documents, tracks every revision automatically, and includes built-in multi-master replication with automatic conflict resolution. Where MongoDB focuses on flexible queries and aggregation pipelines, CouchDB tackles the harder problem: keeping data consistent across distributed systems without constant connectivity.

That makes it the natural choice for offline-first applications, multi-site deployments, and edge computing. Field workers submitting data from remote locations, IoT devices syncing when they come online, multi-region systems that need to stay in sync. These are the problems CouchDB was designed to solve.

We'll put this together by building a field survey app in one TypeScript file. Run everything against a local instance, or create a managed one on Layerbase Cloud if that's more your speed.

Contents

Create a CouchDB Instance

Local with SpinDB

SpinDB is the quickest way to get a local CouchDB instance. It handles the binary download and configuration for you, no Docker needed. (What is SpinDB?)

Install SpinDB globally:

bash
npm i -g spindb    # npm
pnpm add -g spindb # pnpm

Or run it directly without installing:

bash
npx spindb create couch1 -e couchdb --start  # npm
pnpx spindb create couch1 -e couchdb --start # pnpm

If you installed globally, create and start a CouchDB instance:

bash
spindb create couch1 -e couchdb --start

SpinDB downloads the CouchDB binary for your platform, configures it, and starts the server. Verify it's running:

bash
spindb url couch1
text
http://admin:password@127.0.0.1:5984

You can confirm CouchDB is responding by hitting the root URL directly:

bash
curl http://admin:password@127.0.0.1:5984/
text
{"couchdb":"Welcome","version":"3.4.2","features":["access-ready","partitioned","pluggable-storage-engines","reshard","scheduler"],"vendor":{"name":"The Apache Software Foundation"}}

That JSON response is a preview of how CouchDB works: everything is HTTP, everything returns JSON.

Leave the server running.

Layerbase Cloud

Rather skip the local install? Layerbase Cloud provisions managed CouchDB instances. Grab your connection URL from the Quick Connect panel once it's up.

Cloud instances use TLS, so the connection URL will look different:

typescript
const couch = nano('https://user:pass@cloud.layerbase.dev:PORT')

Everything else in this guide works the same either way. Just swap in your connection details.

Set Up the Project

bash
mkdir couchdb-surveys && cd couchdb-surveys
pnpm init
pnpm add nano
pnpm add -D tsx typescript @types/node

Create a file called surveys.ts. All the code in this post goes into that one file.

The nano package is the official Apache CouchDB client for Node.js. It wraps CouchDB's HTTP API in a clean TypeScript-friendly interface.

The Survey Dataset

Here are 10 field survey responses from a research team collecting data across multiple locations. Each document has a custom string ID, surveyor name, location, date, a nested responses object, tags, and a synced flag. This models a realistic offline-first scenario where field workers submit surveys from remote locations:

typescript
type Survey = {
  _id: string
  surveyor: string
  location: string
  date: string
  responses: Record<string, string | number>
  tags: string[]
  synced: boolean
}

const surveys: Survey[] = [
  {
    _id: 'survey-001',
    surveyor: 'Alice',
    location: 'Station A - North Ridge',
    date: '2026-03-01',
    responses: {
      waterQuality: 'clear',
      wildlifeObserved: 'deer, hawk',
      satisfaction: 5,
      notes: 'Excellent conditions, no signs of erosion',
    },
    tags: ['water', 'wildlife'],
    synced: true,
  },
  {
    _id: 'survey-002',
    surveyor: 'Bob',
    location: 'Station B - River Delta',
    date: '2026-03-01',
    responses: {
      waterQuality: 'murky',
      wildlifeObserved: 'heron, fish',
      satisfaction: 3,
      notes: 'Sediment levels higher than last visit',
    },
    tags: ['water', 'wildlife', 'erosion'],
    synced: true,
  },
  {
    _id: 'survey-003',
    surveyor: 'Alice',
    location: 'Station C - Pine Forest',
    date: '2026-03-02',
    responses: {
      waterQuality: 'n/a',
      wildlifeObserved: 'owl, fox',
      satisfaction: 4,
      notes: 'Trail conditions good, some fallen trees',
    },
    tags: ['wildlife', 'trail'],
    synced: false,
  },
  {
    _id: 'survey-004',
    surveyor: 'Carlos',
    location: 'Station A - North Ridge',
    date: '2026-03-02',
    responses: {
      waterQuality: 'clear',
      wildlifeObserved: 'none',
      satisfaction: 4,
      notes: 'Water levels normal, no wildlife spotted',
    },
    tags: ['water'],
    synced: true,
  },
  {
    _id: 'survey-005',
    surveyor: 'Diana',
    location: 'Station D - Coastal Bluff',
    date: '2026-03-03',
    responses: {
      waterQuality: 'salty',
      wildlifeObserved: 'seal, pelican, crab',
      satisfaction: 5,
      notes: 'High biodiversity, tide pools thriving',
    },
    tags: ['water', 'wildlife', 'coastal'],
    synced: true,
  },
  {
    _id: 'survey-006',
    surveyor: 'Bob',
    location: 'Station C - Pine Forest',
    date: '2026-03-03',
    responses: {
      waterQuality: 'n/a',
      wildlifeObserved: 'deer',
      satisfaction: 2,
      notes: 'Trail heavily damaged from recent storm',
    },
    tags: ['trail', 'damage'],
    synced: false,
  },
  {
    _id: 'survey-007',
    surveyor: 'Alice',
    location: 'Station B - River Delta',
    date: '2026-03-04',
    responses: {
      waterQuality: 'clear',
      wildlifeObserved: 'heron, otter',
      satisfaction: 4,
      notes: 'Sediment levels improved since last visit',
    },
    tags: ['water', 'wildlife'],
    synced: true,
  },
  {
    _id: 'survey-008',
    surveyor: 'Carlos',
    location: 'Station D - Coastal Bluff',
    date: '2026-03-04',
    responses: {
      waterQuality: 'salty',
      wildlifeObserved: 'seal',
      satisfaction: 3,
      notes: 'Erosion visible on south face of bluff',
    },
    tags: ['coastal', 'erosion'],
    synced: true,
  },
  {
    _id: 'survey-009',
    surveyor: 'Diana',
    location: 'Station A - North Ridge',
    date: '2026-03-05',
    responses: {
      waterQuality: 'slightly murky',
      wildlifeObserved: 'hawk, rabbit',
      satisfaction: 4,
      notes: 'Recent rainfall affecting water clarity',
    },
    tags: ['water', 'wildlife'],
    synced: false,
  },
  {
    _id: 'survey-010',
    surveyor: 'Alice',
    location: 'Station D - Coastal Bluff',
    date: '2026-03-05',
    responses: {
      waterQuality: 'salty',
      wildlifeObserved: 'pelican, starfish',
      satisfaction: 5,
      notes: 'Best conditions observed this season',
    },
    tags: ['water', 'wildlife', 'coastal'],
    synced: true,
  },
]

Connect and Create a Database

In CouchDB, a database is a collection of JSON documents (similar to a collection in MongoDB or a table in PostgreSQL). Connect to CouchDB using the URL from spindb url couch1 and create a database for our surveys:

typescript
import nano from 'nano'

const couch = nano('http://admin:password@127.0.0.1:5984')

const DB_NAME = 'surveys'

// Clean up from previous runs
const existingDbs = await couch.db.list()
if (existingDbs.includes(DB_NAME)) {
  await couch.db.destroy(DB_NAME)
}

await couch.db.create(DB_NAME)
const db = couch.use<Survey>(DB_NAME)

console.log(`Created database "${DB_NAME}"`)

The couch.use<Survey>() call gives us a typed handle to the database. All subsequent operations go through this db object.

Insert Documents

CouchDB stores JSON documents. Each needs a unique _id. If you don't provide one, CouchDB generates a UUID. Our survey data uses custom string IDs (survey-001, survey-002, etc.) so field workers can create predictable identifiers offline:

typescript
console.log('Inserting surveys...')

for (const survey of surveys) {
  await db.insert(survey)
}

console.log(`Inserted ${surveys.length} surveys`)

Each db.insert() call returns the new document's _id and _rev. The _rev (revision) is central to how CouchDB works. We'll explore that shortly.

Get a Document by ID

Retrieve a single document by its _id:

typescript
const doc = await db.get('survey-001')

console.log(`\nSurvey ${doc._id}:`)
console.log(`  Surveyor: ${doc.surveyor}`)
console.log(`  Location: ${doc.location}`)
console.log(`  Satisfaction: ${doc.responses.satisfaction}`)
console.log(`  Rev: ${doc._rev}`)
text
Survey survey-001:
  Surveyor: Alice
  Location: Station A - North Ridge
  Satisfaction: 5
  Rev: 1-a1b2c3d4e5f6...

Notice the _rev field. The 1- prefix means this is the first revision of the document. Every document in CouchDB carries its revision history.

Update with Revision Tracking

CouchDB uses optimistic concurrency control. To update a document, you must include the current _rev. If someone else modified the document since you read it, your update fails with a conflict instead of silently overwriting their changes:

typescript
// Fetch the current document (need latest _rev)
const current = await db.get('survey-003')
console.log(`\nBefore update: synced=${current.synced}, rev=${current._rev}`)

// Update it (must include _rev)
const updated = {
  ...current,
  synced: true,
  responses: {
    ...current.responses,
    notes: current.responses.notes + '. Synced from field device.',
  },
}

const response = await db.insert(updated)
console.log(`After update: rev=${response.rev}`)

// Fetch again to confirm
const confirmed = await db.get('survey-003')
console.log(`Confirmed: synced=${confirmed.synced}`)
console.log(`Notes: ${confirmed.responses.notes}`)
text
Before update: synced=false, rev=1-a1b2c3d4e5f6...
After update: rev=2-b2c3d4e5f6a1...
Confirmed: synced=true
Notes: Trail conditions good, some fallen trees. Synced from field device.

The revision jumped from 1- to 2-. CouchDB knows this document has been modified once since creation. This revision chain is what makes conflict-free replication possible. When two nodes sync, CouchDB detects and resolves conflicting edits automatically.

If you try to update with a stale _rev, CouchDB rejects the write:

typescript
try {
  // This will fail because current._rev is now outdated
  await db.insert({ ...current, synced: false })
} catch (error) {
  console.log(`\nExpected conflict: ${(error as Error).message}`)
}
text
Expected conflict: Document update conflict.

You never lose data silently. Every conflicting write gets caught and can be resolved explicitly.

List All Documents

Retrieve all documents in the database:

typescript
const allDocs = await db.list({ include_docs: true })

console.log(`\nAll surveys (${allDocs.rows.length} documents):`)
for (const row of allDocs.rows) {
  if (row.doc) {
    const s = row.doc as Survey & { _rev: string }
    console.log(`  ${s._id}  ${s.surveyor.padEnd(8)} ${s.location}`)
  }
}
text
All surveys (10 documents):
  survey-001  Alice    Station A - North Ridge
  survey-002  Bob      Station B - River Delta
  survey-003  Alice    Station C - Pine Forest
  survey-004  Carlos   Station A - North Ridge
  survey-005  Diana    Station D - Coastal Bluff
  survey-006  Bob      Station C - Pine Forest
  survey-007  Alice    Station B - River Delta
  survey-008  Carlos   Station D - Coastal Bluff
  survey-009  Diana    Station A - North Ridge
  survey-010  Alice    Station D - Coastal Bluff

The include_docs: true option tells CouchDB to return the full document body alongside each row. Without it, you only get _id and _rev.

The HTTP API

CouchDB is built entirely on HTTP. Every operation you've done through nano is a standard REST call under the hood. You can talk to CouchDB with nothing but curl:

bash
# Get server info
curl http://admin:password@127.0.0.1:5984/

# List all databases
curl http://admin:password@127.0.0.1:5984/_all_dbs

# Get a single document
curl http://admin:password@127.0.0.1:5984/surveys/survey-001

# Get all documents
curl http://admin:password@127.0.0.1:5984/surveys/_all_docs?include_docs=true
text
["_replicator","_users","surveys"]

No binary protocol, no special driver. Any language or tool that speaks HTTP can talk to CouchDB. That makes it uniquely easy to debug and integrate with. Inspect your data in a browser, test queries with curl, build clients in any language without waiting for an official SDK.

Views with MapReduce

CouchDB's primary query mechanism is MapReduce views. You write a JavaScript map function that emits key-value pairs for each document, CouchDB indexes the output, and you query by key. Views live in design documents, which are just regular documents with an _id starting with _design/.

Create a view that indexes surveys by location:

typescript
const designDoc = {
  _id: '_design/surveys',
  views: {
    byLocation: {
      map: `function(doc) {
        if (doc.location) {
          emit(doc.location, 1);
        }
      }`,
      reduce: '_count',
    },
  },
}

await db.insert(designDoc)
console.log('\nCreated design document with byLocation view')

Now query the view to get survey counts grouped by location:

typescript
const byLocation = await db.view('surveys', 'byLocation', {
  group: true,
})

console.log('\nSurveys by location:')
for (const row of byLocation.rows) {
  console.log(`  ${String(row.key).padEnd(30)} ${row.value} surveys`)
}
text
Surveys by location:
  Station A - North Ridge        3 surveys
  Station B - River Delta        2 surveys
  Station C - Pine Forest        2 surveys
  Station D - Coastal Bluff      3 surveys

The map function runs once per document and emits the location as the key with 1 as the value. The _count reduce function (a built-in) sums up the values for each unique key. CouchDB also provides _sum and _stats as built-in reducers.

Views are incrementally updated. After the initial index build, CouchDB only processes new or changed documents. On large datasets, this makes views very efficient for repeated queries.

Mango Queries

CouchDB also supports a MongoDB-style query language called Mango, which is handy for ad-hoc queries when you don't want to define views upfront. First, create an index:

typescript
await db.createIndex({
  index: {
    fields: ['surveyor', 'responses.satisfaction'],
  },
  name: 'surveyor-satisfaction-index',
})

console.log('\nCreated Mango index on surveyor + satisfaction')

Now query using selectors:

typescript
const results = await db.find({
  selector: {
    surveyor: 'Alice',
    'responses.satisfaction': { $gte: 4 },
  },
  fields: ['_id', 'surveyor', 'location', 'date', 'responses.satisfaction'],
})

console.log(`\nAlice's high-satisfaction surveys:`)
for (const doc of results.docs) {
  console.log(
    `  ${doc._id}  ${doc.location}  satisfaction=${doc.responses.satisfaction}`,
  )
}
text
Alice's high-satisfaction surveys:
  survey-001  Station A - North Ridge  satisfaction=5
  survey-007  Station B - River Delta  satisfaction=4
  survey-010  Station D - Coastal Bluff  satisfaction=5

Mango queries use the same selector syntax as MongoDB's find, supporting $eq, $gt, $gte, $lt, $lte, $ne, $in, $nin, $exists, $regex, and boolean operators $and, $or, $not. For production use, always create an index that covers your query fields. Without one, CouchDB falls back to a full scan and logs a warning.

Replication

Replication is CouchDB's defining feature. One line replicates an entire database:

typescript
// Create a backup database
await couch.db.create('surveys_backup')

// Replicate everything
const replication = await couch.db.replicate(
  'surveys',
  'surveys_backup',
)

console.log(`\nReplication complete:`)
console.log(`  Documents read: ${replication.history[0].docs_read}`)
console.log(`  Documents written: ${replication.history[0].docs_written}`)

// Verify the backup has all documents
const backupDb = couch.use<Survey>('surveys_backup')
const backupDocs = await backupDb.list()
console.log(`  Backup contains: ${backupDocs.rows.length} documents`)
text
Replication complete:
  Documents read: 11
  Documents written: 11
  Backup contains: 11

The document count is 11 (not 10) because the design document is replicated too. This same replication mechanism works across different servers, data centers, or continents. You can replicate from a local CouchDB to a remote one by providing a full URL:

typescript
// Replicate to a remote CouchDB (example)
await couch.db.replicate('surveys', 'http://user:pass@remote-server:5984/surveys')

CouchDB replication is incremental. After the initial sync, only changed documents get transferred. It's also resumable: if replication is interrupted, it picks up where it left off. You can set up continuous replication to keep two databases in sync in real time, or trigger one-off replications as needed.

This is what makes offline-first architectures practical. A field device runs a local CouchDB, collects data without network access, and syncs everything when it reconnects. Conflicts are detected through the revision history and resolved programmatically.

When to Use CouchDB

CouchDB earns its place when the problem involves:

  • Offline-first applications: field work, mobile apps, and IoT devices that need local databases syncing when connectivity is available
  • Multi-site sync: keeping data consistent across locations, data centers, or edge nodes without complex replication infrastructure
  • Edge computing: local databases on edge devices that replicate to a central server, handling intermittent connectivity gracefully
  • Audit trails: built-in revision history gives you a natural log of every change to every document
  • HTTP-native systems: if your architecture is already HTTP-based (microservices, serverless, webhooks), CouchDB fits without adding binary protocols
  • Multi-master deployments: multiple nodes accept writes simultaneously, with CouchDB handling conflict detection and resolution

The common thread: data that needs to live in multiple places and sync reliably without guaranteed connectivity. That's the exact problem CouchDB was designed around.

Wrapping Up

The full script covers document CRUD, revision tracking, MapReduce views, Mango queries, and replication. Any language that speaks HTTP can talk to CouchDB, but the nano package keeps the TypeScript code clean and typed.

The CouchDB documentation covers continuous replication, conflict resolution strategies, partitioned databases, and authentication configuration.

To manage your local CouchDB instance:

bash
spindb stop couch1    # Stop the server
spindb start couch1   # Start it again
spindb list           # See all your database instances

SpinDB manages 20+ engines, so CouchDB can run alongside Valkey, MariaDB, or whatever else your project needs. Layerbase Desktop wraps the same thing in a macOS GUI if you prefer that to the command line.

Something not working?