Jesse A. Green / notebook

json-server Administrator & Developer Guide

Generated April 2026 from typicode/json-server at 89a34a4

AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it.

This guide is the operator's and contributor's reference for json-server 1.0.0-beta.15. It covers architecture, every flag and configuration knob, deployment patterns, security posture, performance characteristics, and the internals required to extend or embed the library. If you only need to use the CLI to mock an API for a frontend, the User Guide has everything required.

The codebase analyzed here is the post-rewrite v1 line: TypeScript source under src/, @tinyhttp for the HTTP layer, lowdb for persistence, and Node's native parseArgs for the CLI. It is intentionally a much smaller surface than 0.17.x — many features from that line (custom routes, middleware files, --delay, --routes, plugins) have not been re-introduced and are explicitly out of scope here.


1. Architecture Overview

json-server is a thin HTTP wrapper around an in-memory data store, with a normalizing adapter layer between the store and the on-disk JSON file. The single-file CLI (src/bin.ts) wires everything together; the HTTP routes live in src/app.ts; the data semantics live in src/service.ts. Every other file is a small focused helper.

                          ┌──────────────────────────────────────────────┐
                          │                  HTTP Client                 │
                          │     (browser, curl, Postman, fetch, axios)   │
                          └─────────────────────┬────────────────────────┘
                                                │ HTTP/1.1
                                                ▼
        ┌──────────────────────────────────────────────────────────────────┐
        │                          @tinyhttp/app                           │
        │   middleware chain:  sirv(public)  -> sirv(-s dirs) ->           │
        │                      cors -> milliparsec(json) -> route handlers │
        └─────────────────────────────────────┬────────────────────────────┘
                                              │
                                              ▼
        ┌──────────────────────────────────────────────────────────────────┐
        │                            src/app.ts                            │
        │  GET  /                       -> Eta render(views/index.html)    │
        │  GET  /:name                  -> Service.find()                  │
        │  GET  /:name/:id              -> Service.findById()              │
        │  POST /:name                  -> Service.create()                │
        │  PUT  /:name | /:name/:id     -> Service.update / updateById     │
        │  PATCH /:name | /:name/:id    -> Service.patch  / patchById      │
        │  DELETE /:name/:id            -> Service.destroyById()           │
        │                                                                  │
        │  parseListParams() splits query string:                          │
        │    reserved keys -> _sort / _page / _per_page / _embed / _where  │
        │    everything else -> parseWhere() into a where-clause object    │
        └─────────────────────────────────────┬────────────────────────────┘
                                              │
                                              ▼
        ┌──────────────────────────────────────────────────────────────────┐
        │                          src/service.ts                          │
        │       Class: Service(db: Low<Data>)                              │
        │       Methods: has, find, findById, create, update, updateById,  │
        │                patch, patchById, destroyById                     │
        │       Helpers: matchesWhere, paginate, embed,                    │
        │                nullifyForeignKey, deleteDependents               │
        └─────────────────────────────────────┬────────────────────────────┘
                                              │
                                              ▼
        ┌──────────────────────────────────────────────────────────────────┐
        │                            lowdb Low                             │
        │   in-memory state mirrored to disk on every write                │
        └─────────────────────────────────────┬────────────────────────────┘
                                              │
                                              ▼
        ┌──────────────────────────────────────────────────────────────────┐
        │                src/adapters/observer.ts (Observer)               │
        │     fires onReadStart/onReadEnd/onWriteStart/onWriteEnd hooks    │
        │     used by bin.ts to detect external edits & log new endpoints  │
        └─────────────────────────────────────┬────────────────────────────┘
                                              │
                                              ▼
        ┌──────────────────────────────────────────────────────────────────┐
        │       src/adapters/normalized-adapter.ts (NormalizedAdapter)     │
        │   read():  strip $schema, coerce numeric ids -> strings,         │
        │            generate randomId() for items missing id              │
        │   write(): inject $schema = ./node_modules/json-server/schema.json│
        └─────────────────────────────────────┬────────────────────────────┘
                                              │
                                              ▼
        ┌──────────────────────────────────────────────────────────────────┐
        │                lowdb base adapter (chosen by extname)            │
        │     .json  -> JSONFile                                           │
        │     .json5 -> DataFile  (JSON5.parse / JSON5.stringify)          │
        │   underlying disk write goes through  steno  for atomic rename   │
        └─────────────────────────────────────┬────────────────────────────┘
                                              │
                                              ▼
                                   db.json on disk
                                              ▲
                                              │ chokidar.watch(file)
                                              │ (re-read on external change,
                                              │  unless NODE_ENV=production)
                                              │
                                              └─── editor / human / external script

Note

The adapter chain (Observer → NormalizedAdapter → JSONFile/DataFile) is the load-bearing detail of the architecture. It is the only mechanism by which db.json is read or written, which keeps the higher layers (Service, route handlers) ignorant of disk semantics. Stable IDs, the $schema injection, and the file-watch coordination all live here, not in the routing or service layer.

Component inventory

Component Path Role
CLI entry point src/bin.ts Parses process.argv, opens the DB, wires the watcher, calls createApp, calls app.listen.
HTTP app builder src/app.ts Exports createApp(db, options). Mounts middleware and route handlers.
Domain service src/service.ts Class Service. Pure data operations on the in-memory Low<Data> store.
Where-clause matcher src/matches-where.ts Pure function matchesWhere(item, where). Recursively evaluates the JSON predicate tree.
Where-clause parser src/parse-where.ts Pure function parseWhere(queryString). Translates flat query strings into the predicate tree.
Operator catalog src/where-operators.ts Exports WHERE_OPERATORS constant array. Single source of truth for valid operator names.
Paginator src/paginate.ts Pure function paginate(items, page, perPage). Returns {first, prev, next, last, pages, items, data}.
ID generator src/random-id.ts randomBytes(8).toString('base64url').
Normalizing adapter src/adapters/normalized-adapter.ts Strips $schema on read, ensures string IDs, re-injects $schema on write.
Observer adapter src/adapters/observer.ts Wraps any Adapter<T> with onReadStart/End and onWriteStart/End hooks.
Index template views/index.html Eta template rendered at GET /.
JSON Schema schema.json Draft-2020-12 schema enforcing top-level keys map to arrays or objects.
Type-checker config tsconfig.json TypeScript compiler settings.
Formatter config .oxfmtrc.json { "semi": false, "singleQuote": true }.

Technology stack

Layer Choice Version
Language TypeScript ^5.9.3
HTTP framework @tinyhttp/app ^3.0.1
CORS @tinyhttp/cors ^2.0.1
Logger (declared, not used in createApp) @tinyhttp/logger ^2.1.0
Body parser milliparsec ^5.1.0
Static files sirv ^3.0.2
Templating (/ index page) eta ^4.5.0
Persistence lowdb ^7.0.1 (via steno for atomic writes)
Object-path setter dot-prop ^10.1.0
Multi-key sort sort-on ^7.0.0
Pluralize/singularize inflection ^3.0.2
JSON5 reader/writer json5 ^2.2.3
File watcher chokidar ^5.0.0
Terminal color chalk ^5.6.2
Test runner node:test (built-in) —
Linter oxlint ^1.39.0
Formatter oxfmt ^0.24.0

Data flow — end to end

A typical GET /posts?views:gt=100&_sort=-views&_page=1&_per_page=2:

  1. @tinyhttp matches app.get('/:name') with name=posts.
  2. parseListParams(req) builds { where: { views: { gt: 100 } }, sort: '-views', page: 1, perPage: 2, embed: undefined }. Reserved keys (_sort, _page, _per_page, _embed, _where) are split out from the filter params before parseWhere runs.
  3. The handler calls service.find('posts', opts), which:
    • Reads db.data['posts'] (an array of Item).
    • Applies embed() for each requested relation.
    • Filters with matchesWhere.
    • Sorts with sort-on against the comma-split sort spec.
    • Returns paginate(results, 1, 2) if _page is set, otherwise the raw array.
  4. The result is stashed at res.locals['data'] and next() is called.
  5. The fallback app.use('/:name', ...) writes the response: 404 if data === undefined, status 201 if the method was POST, otherwise 200 with the JSON body.

A POST /posts follows the same outline, but the body is parsed by milliparsec, validated by the inline isItem() check (returning 400 Body must be a JSON object if it fails), and the resulting item is await service.create(name, body). service.create assigns randomId(), pushes into the array, calls await db.write(), and returns the new item.

A DELETE /posts/1?_dependent=comments first removes the matching item, then runs nullifyForeignKey(db, 'posts', '1') across every other resource (setting postId: null wherever it was '1'), then runs deleteDependents(db, 'posts', ['comments']) which filters out comments whose postId is now null. Finally await db.write() flushes everything.


2. System Requirements

Requirement Minimum Recommended Reason
Node.js 22.12.0 22.x LTS or newer Enforced by package.json engines.node. Several deps (chokidar@5, readdirp@5) require Node >=20.19.
OS Any with Node 22 Linux/macOS preferred Pure JS — Windows works fine but path normalization in tests assumes POSIX in a few places.
RAM ~64 MB free More for larger DBs Whole DB held in memory, plus framework overhead.
Disk ~25 MB — node_modules for the package and its tree (@tinyhttp, lowdb, etc.).
Network None for runtime Outbound for install Initial install pulls from npm registry.
Filesystem Standard POSIX semantics Full inotify/fsevents support The chokidar watcher relies on filesystem events; some bind-mounts (Docker on macOS, WSL2) suppress them.

Production (don't) requirements

json-server is not designed to be deployed to production. If you nonetheless run it on a server, plan for:

  • Single-process, single-threaded — no clustering, no worker pool.
  • Whole-file rewrite on every mutating request — write throughput is bounded by disk I/O for the entire DB, not per-row.
  • No auth, rate limiting, or request validation beyond JSON parsing.
  • Memory grows linearly with DB size; there is no streaming read path.

For mock APIs in CI or a teammate's reachable laptop, none of these matter. For anything beyond that, see Section 7 Security.


3. Configuration Reference

The full configuration surface of json-server is small enough to fit in one section. Everything is here.

Configuration files

File Format Location Purpose
<file> (positional CLI arg) JSON or JSON5 Anywhere on disk The database. Required. Top-level keys become resources.
package.json engines.node JSON Inside the package Engine constraint. Set to >=22.12.0. Not user-tunable.
schema.json JSON Schema 2020-12 Inside the package (./node_modules/json-server/schema.json) Editor-side validation hook. Auto-injected as $schema into your DB file on every write.
.oxfmtrc.json JSON Inside the package Source-formatter config ({semi: false, singleQuote: true}). Build-only.
tsconfig.json JSON Inside the package TS compiler config. Build-only.

There is no user-editable configuration file for runtime behavior. There is no json-server.json, no .json-serverrc, no config/ directory. Behavior changes are made by:

  • CLI flags (the parseArgs schema in src/bin.ts)
  • Environment variables (PORT, HOST, NODE_ENV)
  • The shape of your data file

Important

If you are migrating from 0.17.x documentation, you may see references to routes.json, server.js, middleware files, and --routes <path>. None of these exist in 1.0.0-beta.15. Configuration of custom routes or middleware happens by wrapping json-server rather than configuring it.

Command-line flags — complete

The full schema is the options block passed to node:util.parseArgs in src/bin.ts.

Flag Short parseArgs type Default Multiple Description
--port <port> -p string (parsed to number) process.env['PORT'] ?? '3000' no TCP port. Parsed via parseInt(values.port).
--host <host> -h string process.env['HOST'] ?? 'localhost' no Bind address.
--static <dir> -s string [] yes Each occurrence appends a directory. Path is resolved against process.cwd() if not absolute.
--help — boolean undefined no Print the inline help() message and exit.
--version — boolean undefined no Print pkg.version (read from ../package.json) and exit.
--watch -w boolean undefined no Deprecated. Logs a yellow warning. Watching is on by default in non-production.

Positionals: exactly one — the database file path. parseArgs({allowPositionals: true}). If positionals.length === 0 (or --help), the help message is printed and the process exits. Unknown options throw ERR_PARSE_ARGS_UNKNOWN_OPTION, which is caught and rendered in red, followed by the help message and process.exit(1).

Environment variables — complete

Variable Type Default Used in Effect
PORT string (parsed to number) none src/bin.ts:46 (CLI default) Default value for -p if no flag is passed. CLI flag wins.
HOST string none src/bin.ts:51 (CLI default) Default value for -h if no flag is passed. CLI flag wins.
NODE_ENV string none src/app.ts:16, src/bin.ts:178 Compared === 'production'. When production: (1) Eta template cache is enabled; (2) sirv dev: false mode is used (HTTP caching headers, no live-reload affordances); (3) the chokidar watcher in bin.ts is not started.

Note

No other environment variables are read by json-server. There is no JSON_SERVER_* prefix, no .env loading, no dotenv-style overrides.

Reserved query parameters — complete

These five (six counting _dependent on DELETE) are recognized by route handlers and never passed through to filters.

Param Routes Type Default Purpose
_sort GET /:name string (comma-separated) (no sort) Multi-key sort. Prefix with - for descending. Forwarded to sort-on(items, key.split(',')).
_page GET /:name integer (no pagination) Page number, 1-indexed. Triggers the wrapped pagination response shape.
_per_page GET /:name integer 10 (when _page is set) Page size. Clamped to ≥1.
_embed GET /:name, GET /:name/:id string or repeated (no embed) Embed related resources. May be repeated. Both singular (parent) and plural (children) forms supported.
_where GET /:name URL-encoded JSON (parsed from other params) JSON predicate tree. Overrides the where-clause computed from regular query params if present and parses as a non-null object.
_dependent DELETE /:name/:id string or repeated (just nullify FKs) Names additional resources whose foreign-key-bearing rows should be deleted (cascade) instead of orphaned.

Where operators — complete

The single source of truth is src/where-operators.ts:

export const WHERE_OPERATORS = [
  'lt', 'lte', 'gt', 'gte', 'eq', 'ne',
  'in', 'contains', 'startsWith', 'endsWith',
] as const
Operator Comparison Notes
eq field === value Default if no operator is specified.
ne field !== value
lt field < value JavaScript <. Works for numbers, strings (lexicographic), booleans (cast).
lte field <= value
gt field > value
gte field >= value
in value.split(',').includes(field) Comma-separated list. Each element is value-coerced (numbers/booleans/null).
contains field.toLowerCase().includes(value.toLowerCase()) String only. Returns false if field is not a string.
startsWith field.toLowerCase().startsWith(value.toLowerCase()) String only.
endsWith field.toLowerCase().endsWith(value.toLowerCase()) String only.
or logical OR over an array of sub-queries Only valid inside a _where JSON object: {or: [{a:{eq:1}}, {b:{eq:2}}]}. Not exposed as field:or=... — it's a top-level key of the where object.

Operators not in this list are silently dropped during parsing (see parseWhere in src/parse-where.ts:21-23). This is the cause of issue #1731.

Value coercion in parseWhere

The coerceValue helper in src/parse-where.ts runs on every operand from a query string:

Input Output
"true" true
"false" false
"null" null
"" (whitespace-only) "" (kept as string)
"42", "3.14", "-1" 42, 3.14, -1 (any Number.isFinite result)
anything else the original string

Important

A query value of "40123456" becomes the number 40123456 after coercion. If your stored id is the string "40123456", an eq comparison will fail because the operator implementation uses strict ===. Track this gotcha if random base64url IDs happen to be all-digit strings (issue #1734).

Programmatic configuration

If you embed json-server into a larger Node program:

import { createApp } from 'json-server/lib/app.js'  // post-build path
import { Low, Memory } from 'lowdb'

const db = new Low(new Memory(), { posts: [], comments: [] })
const app = createApp(db, {
  logger: false,           // boolean, not currently honored beyond shape
  static: ['./public2'],   // string[], same semantics as -s flag
})

app.listen(3000)

AppOptions (from src/app.ts:18):

export type AppOptions = {
  logger?: boolean
  static?: string[]
}

logger is a declared option but not currently consumed by any branch in createApp — passing it has no effect. static is the equivalent of one or more -s flags.


4. Deployment & Scaling

json-server is not built for horizontal scaling. The "deployment" question is mostly about how to keep one process running where you need it, with clear awareness of its limits.

npx json-server db.json

Bound to localhost:3000, serving one developer's machine. Stop with Ctrl-C. This is the design center.

LAN-reachable

HOST=0.0.0.0 PORT=4000 npx json-server db.json

For mobile-device testing or pairing with a colleague. Keep on a trusted network — see Security.

Behind a reverse proxy

The only viable production-shaped deployment. Run json-server on localhost, and front it with a real HTTP server that adds the missing concerns: TLS, auth, rate limiting, logging, route prefixing, latency injection.

Caddy:

api.example.com {
    basicauth /* {
        admin <bcrypt-hash>
    }
    reverse_proxy localhost:3000
}

Nginx:

server {
    listen 443 ssl http2;
    server_name api.example.com;

    auth_basic           "json-server";
    auth_basic_user_file /etc/nginx/.htpasswd;

    location /api/ {
        proxy_pass http://127.0.0.1:3000/;
    }
}

Vite dev server (in a frontend project):

// vite.config.js
export default {
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        rewrite: (path) => path.replace(/^\/api/, ''),
      },
    },
  },
}

Containerized (development only)

There is no official Dockerfile. A reasonable one:

FROM node:22-alpine
WORKDIR /app
RUN npm install -g json-server
COPY db.json ./db.json
EXPOSE 3000
CMD ["json-server", "-h", "0.0.0.0", "db.json"]
docker build -t myapp-mock .
docker run --rm -p 3000:3000 -v $PWD/db.json:/app/db.json myapp-mock

Warning

Bind-mounting db.json into the container is fine for dev, but the file watcher may not fire on every host filesystem (Docker for Mac, WSL2). If watch isn't reliable, edit the file from inside the container or restart the process after edits.

CI / automated tests

Run as a backgrounded process in your test job:

npx json-server -p 4000 fixtures/db.json &
SERVER_PID=$!
trap "kill $SERVER_PID" EXIT

# Wait for it to be ready
until curl -fs http://localhost:4000/ > /dev/null; do sleep 0.2; done

# Run tests
npm test

Or use concurrently (already a devDep of the project):

npx concurrently \
  "npx json-server -p 4000 fixtures/db.json" \
  "npm test"

Scaling — what's possible and what isn't

Want Feasible? How
Multiple instances on different ports Yes Start each with a different -p and a different <file>. They are independent processes with independent state.
Two instances sharing one db.json No Two writers will corrupt the file. lowdb doesn't coordinate cross-process writes.
Read-replicas No No replication mechanism.
Cluster mode (one DB, N workers) No The state is a per-process in-memory object; there is no shared store.
Sticky sessions N/A No session state.
Vertical scaling (bigger box) Yes Helps for read-heavy workloads. Write throughput is bounded by single-threaded full-file rewrites.

The honest answer: if you find yourself wanting to scale json-server past one process, you've outgrown it. Move to a real backend.


5. Operations

Run-the-thing concerns. Most are short because the surface is small.

Starting and stopping

# foreground
npx json-server db.json

# backgrounded (POSIX)
nohup npx json-server db.json > json-server.log 2>&1 &
echo $! > json-server.pid

# stop
kill $(cat json-server.pid)

The process responds to SIGINT (Ctrl-C) and SIGTERM cleanly — app.listen returns a Node http.Server with default close semantics.

Health checking

There is no dedicated health-check endpoint. The cheapest probe is a GET / (always returns the rendered index page if the DB loaded successfully):

curl -fs http://localhost:3000/ > /dev/null && echo OK

Or any known resource:

curl -fs http://localhost:3000/posts > /dev/null && echo OK

Tip

If you're behind a reverse proxy that needs a strict 200-only health check, expose a tiny static file: mkdir -p public && touch public/healthz and probe GET /healthz.

Logging

There is no built-in request log. The startup banner is the only log line you get out of the box. To add request logging:

  • Run behind a reverse proxy (Caddy/nginx/Traefik all log access by default).

  • Or wrap programmatically and chain in @tinyhttp/logger before app.listen:

    import { logger } from '@tinyhttp/logger'
    // ...
    const app = createApp(db, options)
    app.use(logger())
    app.listen(port, ...)
    

    Note: the order of app.use matters with @tinyhttp — register the logger after createApp returns and before the implicit fallthrough handler runs.

Backups

The DB is a single file. Backups are file copies.

# snapshot
cp db.json "backups/db-$(date +%Y%m%d-%H%M%S).json"

# scheduled snapshot every 5 minutes (cron)
*/5 * * * * cp /srv/json-server/db.json /srv/json-server/backups/db-$(date +\%Y\%m\%d-\%H\%M).json

Note

lowdb's underlying writer (steno) writes to a temp file and then atomically renames it over the target. Even if you copy mid-write, you get either the pre-write or post-write content, never a torn file. Restore is cp in reverse.

Restoring

# stop the server first to avoid re-saving stale in-memory state
kill $(cat json-server.pid)
cp backups/db-20260428-120000.json db.json
nohup npx json-server db.json > json-server.log 2>&1 &

Rotating the database

To reset cleanly (e.g. after a destructive test run):

kill $(cat json-server.pid)
cp seeds/initial.json db.json
nohup npx json-server db.json > json-server.log 2>&1 &

Monitoring

A few signals to watch if you must:

Signal What it tells you How
Process alive Up / down PID file, systemd, supervisor, etc.
Port responsive Listener responding nc -z localhost 3000 or curl -fs /
db.json size Steady-state vs. runaway growth stat db.json or du -h db.json over time
Process RSS Memory pressure ps -o rss= -p $PID
Log file size Background-process noise Rotate json-server.log with logrotate.

Note

There are no metrics endpoints. No /metrics, no Prometheus exposition. Use process-level supervision tools (systemd, supervisord, pm2) for liveness; instrument at the proxy layer for request metrics.

Updating the package

npm view json-server version          # check latest
npm install json-server@latest        # local install
npm install -g json-server@latest     # global install

The 1.0.0-beta.x line is in active development. Pin a specific beta version (json-server@1.0.0-beta.15) in critical projects to avoid surprises.


6. Performance

Because every concern is in-process and small, performance is straightforward — and so are its limits.

Latency profile

For a typical small dataset (hundreds to thousands of items), every operation is dominated by JavaScript dictionary lookups and array operations, not I/O. Expected latencies on a modern dev machine:

Operation Cost dominator Typical latency
GET /:name (no filter) Array traversal + JSON serialize < 5 ms
GET /:name?filter Linear scan, matchesWhere per item O(n) — sub-ms for n<1k
GET /:name?_sort=... sort-on (in-place merge sort) O(n log n)
GET /:name?_page=... Paginate (slice) — no extra cost < 5 ms
GET /:name/:id Linear Array.find by id O(n)
POST /:name randomId() + push + full disk write dominated by disk write
PUT /:name/:id Array.find + splice + full disk write dominated by disk write
PATCH /:name/:id merge + splice + full disk write dominated by disk write
DELETE /:name/:id Array.find + splice + foreign-key sweep + full disk write dominated by disk write

Disk I/O

Every successful write rewrites the entire db.json. There are no partial writes, no append-only mode, no journaling. With a 1 MB DB, that's ~1 MB of disk I/O per POST — fine on SSD, noticeable on HDD.

If you anticipate heavy write churn during a test run:

  • Keep db.json small (only the seed data your test needs).
  • Run on tmpfs if your OS supports it: cp db.json /dev/shm/db.json && npx json-server /dev/shm/db.json (POSIX/Linux only).

Memory

DB size in memory is approximately JSON.parse(db.json).length plus framework overhead (~30 MB resident at idle). A 10 MB JSON file decodes to a roughly proportional in-memory structure. The whole tree is held — there is no streaming or lazy-loading path.

Filtering performance

matchesWhere walks the predicate tree depth-first per item. For a query of k predicates against n items, cost is O(n * k). Operators all run as constant-time JavaScript comparisons except contains/startsWith/endsWith (which lowercase both operands per call — a hot allocation in tight loops over large strings).

Concurrency

Node single-threaded event loop. Every request is processed in order on the main thread. Mutations (POST/PUT/PATCH/DELETE) await db.write(), which blocks the event loop on disk I/O until the write completes — so concurrent writes serialize through the file write. Reads issued during a write don't block the write but will see stale data until the write resolves and the next request is dispatched.

Note

There is no read-write lock. The in-memory db.data object is mutated synchronously during a write (e.g. items.splice in service.destroyById runs before db.write()). A simultaneous GET arriving between the in-memory mutation and the disk flush observes the new state, even though the write hasn't completed on disk. This is acceptable for mocking; it's not transactional.

Scaling cliffs

Scale Behavior
100 items, 1 KB each Imperceptible.
10 K items, 1 KB each Every list-without-filter starts to feel slow if serialized to JSON in full. Pagination hides this.
100 K items List-everything responses get heavy (10s of MB). Filters that don't reduce cardinality are slow. Write latency rises linearly with file size.
1 M items You should not be using json-server.

7. Security

This is the most important section to read carefully. json-server is not secure by default and was never intended to be exposed to untrusted networks.

What json-server is NOT for

  • Production traffic. No SLAs, no clustering, no observability, no auth.
  • Public-internet endpoints. Even read-only — the data file may contain anything you wouldn't share publicly.
  • Untrusted networks. Any client that can reach the port can fully read and rewrite your DB.
  • Sensitive data. No encryption at rest, no field-level access control, no audit trail.

The default security posture (zero defenses)

Concern json-server's default behavior
Authentication None. Every request is anonymous and accepted.
Authorization None. All resources are equally open.
Rate limiting None. A client can issue unbounded writes.
CSRF protection None. No tokens, no Origin checks.
CORS Wide open. @tinyhttp/cors is mounted with the Access-Control-Request-Headers request header reflected, defaulting to allow any Origin.
Input validation Minimal. Only "is this body a JSON object?" is checked; the entire body is stored as-is.
Schema enforcement on writes None at runtime. The $schema reference is for editor tooling only — it is not used to reject invalid bodies.
Output sanitization None. Stored values are echoed back verbatim.
Path traversal Mitigated by sirv (which resolves and locks paths). The -s flag resolves directories against process.cwd().
File write surface The DB file path is the one passed on the CLI. No directory traversal from request data.

Warning

Mounting json-server on a public IP without a reverse-proxy hardening layer is equivalent to publishing your DB file with a public read/write API. There are no built-in mitigations. If you do this, assume the data will be read, modified, or destroyed.

The threat model that's safe

You're safe if all of the following hold:

  • Bound to localhost (the default).
  • Not started inside a process that can be reached from a hostile colocated tenant on the same machine.
  • The DB file contains only fixture/test data.
  • Started, used, and stopped within a single development session.

Hardening recommendations (when you must run it on more than localhost)

Layer Approach
Network Firewall the port to your office IP / VPN range. Bind to a non-default port to reduce drive-by scanner hits.
Reverse proxy Front with Caddy/nginx/Traefik. Add basic auth, IP allowlists, mTLS, or OIDC at the proxy layer. Terminate TLS there.
Auth Implement at the proxy. There is no in-process middleware mechanism in 1.0.0-beta.15 (issue #1617).
Rate limiting At the proxy. Strict per-IP limits — every write rewrites your file.
CORS Override at the proxy if you need a stricter Access-Control-Allow-Origin than the default. The bundled CORS middleware is permissive.
Backups Frequent file snapshots. A single bad PATCH can shape-shift a record permanently.
Process isolation Run as an unprivileged user. Use systemd DynamicUser=yes, AppArmor, or SELinux contexts to limit what the process can touch.
Capability The process needs read/write on the DB file and read on any -s directories. Nothing else.

Embedding into a larger app for in-process auth

The cleanest way to add auth is to mount createApp inside a parent server you control:

import { App } from '@tinyhttp/app'
import { createApp } from 'json-server/lib/app.js'

const parent = new App()

// Custom auth middleware
parent.use((req, res, next) => {
  if (req.headers.authorization !== `Bearer ${process.env.SECRET}`) {
    return res.status(401).json({ error: 'Unauthorized' })
  }
  next()
})

// Mount json-server's app under /api
const jsonServerApp = createApp(db)
parent.use('/api', jsonServerApp)

parent.listen(3000)

This pattern lets you keep json-server's data semantics while owning the surrounding HTTP concerns.

Known security-relevant issues

Issue Status Impact Mitigation
#1731 Fail-open nested _where filtering OPEN A malformed nested predicate can cause filters to silently match more than intended. Validate _where shape on the client before sending; never trust json-server to enforce filter intent on potentially-attacker-controlled queries.
#1617 Middleware option missing OPEN No in-process hook for auth/validation/transformation. Wrap with a parent app (above) or a reverse proxy.

Caution

No CVEs are tracked against json-server 1.0.0-beta.15 at the time of this commit. Absence of a CVE is not a security claim — the package does not advertise security guarantees and is not a hardened component. Treat all production-shaped deployments as defense-in-depth exercises with this layer assumed compromised.

Defaults that are NOT security risks (but look like they should be)

  • The auto-injected $schema pointer is a relative path (./node_modules/json-server/schema.json). Editors with JSON Schema support resolve it locally; it is not fetched at runtime by json-server itself.
  • The base64url ID generator uses crypto.randomBytes(8), a CSPRNG. The entropy is 64 bits — fine for opaque resource IDs in a non-adversarial environment, not strong enough for security tokens.
  • File watching uses chokidar (no shell, no exec). External edits to db.json are read with JSON.parse / JSON5.parse — a malformed file produces a SyntaxError printed in red, not a crash.

8. Developer Internals

Everything an extender or contributor needs.

Repository layout

json-server/
├── src/                           Source code (TypeScript)
│   ├── bin.ts                     CLI entry point. Imports createApp, owns the watch loop.
│   ├── app.ts                     createApp(db, options): builds the @tinyhttp app.
│   ├── service.ts                 class Service: pure data ops on lowdb.
│   ├── matches-where.ts           matchesWhere(item, where): predicate evaluator.
│   ├── parse-where.ts             parseWhere(query): query string -> predicate tree.
│   ├── where-operators.ts         WHERE_OPERATORS constant + isWhereOperator type guard.
│   ├── paginate.ts                paginate(items, page, perPage): page wrapper.
│   ├── random-id.ts               randomId(): base64url 8-byte id.
│   ├── adapters/
│   │   ├── normalized-adapter.ts  NormalizedAdapter: $schema + id normalization.
│   │   ├── observer.ts            Observer: read/write hook adapter.
│   │   └── normalized-adapter.test.ts
│   ├── *.test.ts                  Per-module tests, run via node:test.
├── views/
│   └── index.html                 Eta template rendered at GET /.
├── public/                        Optional static dir (auto-served if it exists).
├── fixtures/
│   ├── db.json                    Sample fixture for `npm run dev`.
│   └── db.json5                   Sample JSON5 fixture.
├── schema.json                    JSON Schema for db.json shape.
├── tsconfig.json                  TS compiler config.
├── .oxfmtrc.json                  oxfmt config.
├── package.json                   Package manifest.
└── pnpm-lock.yaml                 Resolved dependency tree.

Module-by-module reference

src/bin.ts

The CLI entry point. Does five things in sequence:

  1. args() — parse process.argv with node:util.parseArgs. Handles --version (prints from package.json), --help, deprecated --watch (logs a warning), and the unknown-option error path. Returns {file, port, host, static}.
  2. Validate the file path exists. If not, print red error and process.exit(1). If empty, write {} into it.
  3. Build the adapter chain: choose JSONFile or DataFile based on extname(file), wrap with NormalizedAdapter, wrap with Observer. Construct Low<Data>(observer, {}) and call await db.read().
  4. Call createApp(db, {logger: false, static: staticArr}) and app.listen(port, callback). The callback prints the colored startup banner with one of four kaomojis chosen at random.
  5. If NODE_ENV !== 'production': wire the Observer's onReadStart/onReadEnd/onWriteStart/onWriteEnd hooks and call chokidar.watch(file).on('change', ...). The hooks coordinate so that the in-process write doesn't trigger its own re-read. On a real external change, db.read() is awaited; on a parse error the SyntaxError is printed in red.

src/app.ts

Exports createApp(db, options). Order of mounting:

  1. sirv('public', {dev: !isProduction}) — auto-mount the ./public directory.
  2. For each path in options.static: resolve to absolute (against process.cwd() if not absolute) and app.use(sirv(dir, {dev: !isProduction})).
  3. CORS — installs @tinyhttp/cors as a per-request middleware that reflects the Access-Control-Request-Headers header. Also app.options('*', cors()) for preflight.
  4. milliparsec.json() — JSON body parser.
  5. GET / — Eta render of views/index.html with {data: db.data}.
  6. List/show/create/update/patch/delete handlers (see route table in Section 1).
  7. Final app.use('/:name', ...) — sends res.locals['data'] as JSON. 404 Not Found if the data is undefined; 201 if the request was POST; 200 otherwise.

The two helpers withBody and withIdAndBody wrap a Service method into a middleware that validates isItem(req.body) and returns 400 Body must be a JSON object if it fails. Otherwise it stashes the result on res.locals['data'] and calls next().

parseListParams(req) is the only non-trivial function here. It splits the query string by RESERVED_QUERY_KEYS, runs parseWhere on the remainder, and overrides with a parsed _where JSON if present and valid. Returns {where, sort, page, perPage, embed}.

src/service.ts

Class Service encapsulates all data semantics. Constructor takes a Low<Data>.

Method Signature Behavior
has(name) (name: string) => boolean True iff name is a key in db.data.
findById(name, id, query) (name, id, {_embed?}) => Item | undefined Linear Array.find by id. Applies each _embed relation.
find(name, opts) (name, {where, sort?, page?, perPage?, embed?}) => Item[] | PaginatedItems | Item | undefined Embed → matchesWhere → optional sort-on → optional paginate(items, page, perPage ?? 10). If the resource is a singleton (non-array), returns it directly.
create(name, data) (name, body) => Promise<Item | undefined> Append {...data, id: randomId()} to the array. Calls db.write(). Returns the new item.
update(name, body) (name, body) => Promise<Item | undefined> Replace a singleton. Returns undefined for arrays/unknown names.
patch(name, body) (name, body) => Promise<Item | undefined> Merge into a singleton.
updateById(name, id, body) (name, id, body) => Promise<Item | undefined> Replace an array item, preserving its id. Splice in place.
patchById(name, id, body) (name, id, body) => Promise<Item | undefined> Merge fields into an array item by id.
destroyById(name, id, dependent?) (name, id, string | string[]?) => Promise<Item | undefined> Splice the item out. Run nullifyForeignKey across all other resources. Run deleteDependents for each named dependent. Calls db.write(). Returns the removed item.

Two pure helpers in the same file:

  • embed(db, name, item, related) — uses inflection.singularize/pluralize to decide direction and the foreign-key field name (<singular>Id).
  • nullifyForeignKey / deleteDependents — sweep db.data for items with the matching FK.

src/matches-where.ts

Pure recursive evaluator. Public function matchesWhere(obj, where) -> boolean. The or key is special: it expects an array, and any sub-where matching the object is enough. For each non-or key:

  • If value is a JSON object and contains any known operator, the field is checked against each operator. If the field is undefined, returns false.
  • If value is a JSON object without known operators, recurse into a nested object on the same key. (This is the path that flags issue #1731: a malformed sub-predicate falls through to nested traversal.)
  • Otherwise, the predicate is treated as not-yet-supported and returns false.

src/parse-where.ts

Public function parseWhere(queryString) -> JsonObject. Internally:

  • splitKey(rawKey) — strips the operator suffix. First tries colon (field:op=value), then underscore-style (field_op=value) for 0.17 compat, then defaults to eq.
  • coerceValue(rawValue) — booleans/null/numbers/string fallback.
  • setPathOp(out, path, op, value) — uses dot-prop's setProperty to build nested objects from dotted paths. The in operator splits the value on , and coerces each part.

src/paginate.ts

Pure function. Always returns the full PaginationResult<T> shape. Inputs are clamped:

  • perPage: only positive finite integers; otherwise 1.
  • page: clamped to [1, pages].
  • pages = Math.max(1, Math.ceil(items.length / perPage)).

first is always 1. last is always pages. prev/next are null at the boundaries.

src/random-id.ts

export function randomId(): string {
  return randomBytes(8).toString('base64url')
}

8 bytes → 11 base64url chars.

src/where-operators.ts

export const WHERE_OPERATORS = [
  'lt','lte','gt','gte','eq','ne',
  'in','contains','startsWith','endsWith',
] as const
export type WhereOperator = (typeof WHERE_OPERATORS)[number]
export function isWhereOperator(value: string): value is WhereOperator {
  return (WHERE_OPERATORS as readonly string[]).includes(value)
}

The single source of truth for operator names. Adding an operator is a 4-step change: append here, handle in matchesWhere, document in the User Guide and this section, add tests in matches-where.test.ts and parse-where.test.ts.

src/adapters/normalized-adapter.ts

export const DEFAULT_SCHEMA_PATH = './node_modules/json-server/schema.json'

export class NormalizedAdapter implements Adapter<Data> {
  // read():
  //   1. delegate to underlying adapter
  //   2. delete data['$schema']
  //   3. for each array value: coerce numeric `id` to string,
  //      generate randomId() for any item missing `id`
  //
  // write():
  //   1. delegate to underlying adapter with data + {$schema: DEFAULT_SCHEMA_PATH}
}

This is the integrity layer. ID stability and editor tooling both flow from here.

src/adapters/observer.ts

A pass-through adapter that fires onReadStart, onReadEnd(data), onWriteStart, onWriteEnd hooks. Used in bin.ts to suppress the chokidar change event triggered by the app's own writes (the watcher checks writing flag set by onWriteStart/onWriteEnd).

Code patterns and conventions

  • Pure helpers, mutable Service. All data-shape functions (paginate, matchesWhere, parseWhere) are pure. Mutation is concentrated in the Service class and the bin.ts watcher.
  • Strict private fields. Service uses #db. Adapters use #adapter / #data. Public surface is the Adapter<T> interface methods.
  • No exceptions for control flow. Failures (unknown resource, missing id) return undefined; the route layer translates undefined into 404.
  • Type-narrowing guards. isItem, isJSONObject, isWhereOperator are explicit type guards used to keep the rest of the code well-typed.
  • Style. No semicolons (oxfmt config). Single quotes. Strict-types-on tsconfig.

Testing strategy

The test runner is Node's built-in node:test. The test npm script:

npm test
# expands to:
node --experimental-strip-types --test src/*.test.ts

Tests run against the TypeScript source directly (no separate build step) thanks to --experimental-strip-types in Node 22.

Test file Coverage
src/app.test.ts End-to-end: spins up the app on a free port, exercises every method × every route shape, checks status codes and error bodies.
src/service.test.ts Per-method service contract: findById, find, create (id generation/forgery resistance), update, patch, updateById, patchById, destroyById (FK nullify and cascade).
src/matches-where.test.ts Operator semantics for every WHERE_OPERATOR plus nested fields, or, unknown ops, type-mismatch behavior.
src/parse-where.test.ts Query-string parsing including v0.17 underscore syntax, dotted paths, in list parsing, type coercion.
src/paginate.test.ts Boundary cases: clamp high/low pages, _per_page=0, empty input.
src/adapters/normalized-adapter.test.ts $schema strip on read / inject on write, numeric-id coercion, random-id generation for missing ids.

Adding a feature should add tests in the matching file. The existing tests are good models — they're table-driven (for (const tc of cases) ...) and assert with node:assert/strict.

Build and publish

npm run typecheck      # tsc --noEmit
npm run build          # rm -rf lib && tsc           (compiles src -> lib)
npm run prepublishOnly # rm -rf lib && tsc           (auto-runs before npm publish)

Published package contents (per files in package.json):

lib/        compiled JavaScript
views/      Eta templates
schema.json the JSON Schema

The published bin field is lib/bin.js (post-compile path). At runtime, when dist-style features rely on file paths (e.g. views/), those are resolved via import.meta.url — see eta = new Eta({views: join(__dirname, '../views')}) in src/app.ts:21. This means the runtime expects lib/ and views/ to be siblings, which the files array enforces.

Linting and formatting

npm run lint           # oxlint src
npm run fmt            # oxfmt
npm run fmt:check      # oxfmt --check

oxlint and oxfmt are Rust-based — fast, no Node-side overhead in CI. The prepare script wires up husky for pre-commit hooks.

Potential improvements

Worth considering for future betas (none of these are in 1.0.0-beta.15):

  • Re-introduce --delay (#1545, #1558). Simulating latency is the most-requested missing feature.
  • Middleware hook (#1617). A documented place to register pre-route auth/validation logic, even if it's just exposing the underlying App instance.
  • X-Total-Count header (#1727). Easy parity with the older API and many third-party admin UIs that look for it.
  • Custom routes / route prefix — even just an option to mount the API under /api would close #1710-class issues without requiring a reverse proxy.
  • Strict operator validation (#1731). Reject unknown operators inside nested _where rather than silently descending.
  • Configurable embed key suffix (#1634). Allow post_id / parent instead of forcing xxxId.
  • Custom POST response shape (#1619). Pluggable response transformation.
  • File-upload support (#528). Long-standing request; would need either a separate route family or a switch to multipart/form-data parsing.

Contributing

Quick contributor loop:

git clone https://github.com/typicode/json-server.git
cd json-server
npm install
npm run dev -- fixtures/db.json   # live-reload TypeScript runner
# edit src/...
npm test                          # run the suite
npm run lint
npm run fmt
git commit -m "..."               # husky runs pre-commit hooks

Tests live next to the source file they cover (src/foo.ts ↔ src/foo.test.ts). PRs should keep that pattern.

Tip

When changing WHERE_OPERATORS, be sure to update both the matcher (matches-where.ts) and the parser (parse-where.ts) and add table-driven tests to both of their .test.ts files. Adding the constant alone doesn't expose the operator at runtime.


9. Integration Points

How json-server slots into other tools and stacks.

Frontend frameworks

The integration is universal: json-server is just an HTTP server. Point your fetch base URL at it and you're done.

Stack Pattern
Vite (React/Vue/Svelte/Solid) Run on a different port; configure Vite's server.proxy to forward /api to http://localhost:3000.
Next.js Run alongside, use as the data source for getServerSideProps or fetched from client components.
Create React App Set "proxy": "http://localhost:3000" in package.json.
Plain HTML + fetch Same-origin if you serve your HTML via -s, otherwise rely on the open CORS.

Test runners

Run as a backgrounded process during the suite. See "CI / automated tests" in Section 4. The test fixtures themselves live in fixtures/db.json in the project; copy that pattern to seed your own.

Programmatic embedding

createApp is exported from src/app.ts. The published path is json-server/lib/app.js after npm run build. A minimal embed:

import { Low, Memory } from 'lowdb'
import { createApp } from 'json-server/lib/app.js'

const db = new Low(new Memory(), {
  posts: [{ id: '1', title: 'Hello' }],
})

const app = createApp(db, { static: [] })
app.listen(3000)

For real-file persistence:

import { Low } from 'lowdb'
import { JSONFile } from 'lowdb/node'
import { createApp } from 'json-server/lib/app.js'
import { NormalizedAdapter } from 'json-server/lib/adapters/normalized-adapter.js'

const adapter = new NormalizedAdapter(new JSONFile('./db.json'))
const db = new Low(adapter, {})
await db.read()

const app = createApp(db)
app.listen(3000)

Important

When embedding, match the adapter chain that bin.ts constructs (NormalizedAdapter wrapping the file adapter). Skipping NormalizedAdapter means missing IDs won't be auto-generated and numeric IDs won't be coerced — every other layer assumes string IDs.

lowdb adapters

Because json-server accepts any Low<Data>, you can swap in any lowdb adapter:

Adapter Use case
Memory In-memory only; data is lost on restart. Test setups.
JSONFile (default for .json) Single file on disk.
DataFile (used for .json5) Parser/stringifier injection — also useful for YAML or custom serializers.

The Adapter<T> interface is {read(): Promise<T \| null>, write(data: T): Promise<void>}. Anything matching that contract works.

Reverse proxies

See Section 4 Deployment for Caddy, nginx, and Vite proxy snippets. The key constraint is that json-server doesn't honor X-Forwarded-* headers natively — if you want client IPs in your access logs, capture them at the proxy layer.

Observability / Logging tools

There is no integration with structured logging libraries (Pino, Winston) out of the box. Two reasonable patches:

  1. At the proxy layer. Caddy/nginx access logs are usually enough for development.

  2. Inside the embedded app (when using createApp):

    const app = createApp(db)
    app.use((req, _res, next) => {
      console.log(`${new Date().toISOString()} ${req.method} ${req.url}`)
      next()
    })
    app.listen(3000)
    

CI/CD platforms

json-server is happy to run on any worker that has Node 22. No native deps means no platform-specific tweaks. Long-lived test workers can install globally; ephemeral (GitHub Actions, GitLab CI) prefer npx per run.

# .github/workflows/test.yml (example)
- uses: actions/setup-node@v4
  with:
    node-version: '22'
- run: |
    npx json-server -p 4000 fixtures/db.json &
    npm test

What does NOT integrate

For clarity:

  • No webhook or event emission. Mutations don't notify any external system.
  • No streaming / WebSockets / SSE. All endpoints are request/response.
  • No GraphQL. REST only.
  • No authentication providers (OAuth, OIDC, JWT verification). Auth is up to you, at the edge.
  • No queue or pub/sub adapter. State changes are local-only.

Note

json-server's strength is minimalism. Each integration above achieves its goal by leaving json-server alone and adding capability around it. The package itself is intentionally not a platform.