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.jsonis read or written, which keeps the higher layers (Service, route handlers) ignorant of disk semantics. Stable IDs, the$schemainjection, 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:
@tinyhttpmatchesapp.get('/:name')withname=posts.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 beforeparseWhereruns.- The handler calls
service.find('posts', opts), which:- Reads
db.data['posts'](an array ofItem). - Applies
embed()for each requested relation. - Filters with
matchesWhere. - Sorts with
sort-onagainst the comma-split sort spec. - Returns
paginate(results, 1, 2)if_pageis set, otherwise the raw array.
- Reads
- The result is stashed at
res.locals['data']andnext()is called. - The fallback
app.use('/:name', ...)writes the response:404ifdata === undefined, status201if the method wasPOST, otherwise200with 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
parseArgsschema insrc/bin.ts) - Environment variables (
PORT,HOST,NODE_ENV) - The shape of your data file
Important
If you are migrating from
0.17.xdocumentation, you may see references toroutes.json,server.js, middleware files, and--routes <path>. None of these exist in1.0.0-beta.15. Configuration of custom routes or middleware happens by wrappingjson-serverrather 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 noJSON_SERVER_*prefix, no.envloading, 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 number40123456after coercion. If your storedidis the string"40123456", aneqcomparison 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.
Local-only (the default and recommended posture)
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.jsoninto 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/healthzand probeGET /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/loggerbeforeapp.listen:import { logger } from '@tinyhttp/logger' // ... const app = createApp(db, options) app.use(logger()) app.listen(port, ...)Note: the order of
app.usematters with@tinyhttp— register the logger aftercreateAppreturns 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 iscpin 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.jsonsmall (only the seed data your test needs). - Run on
tmpfsif 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.dataobject is mutated synchronously during a write (e.g.items.spliceinservice.destroyByIdruns beforedb.write()). A simultaneousGETarriving 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-serveron 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-server1.0.0-beta.15at 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
$schemapointer is a relative path (./node_modules/json-server/schema.json). Editors with JSON Schema support resolve it locally; it is not fetched at runtime byjson-serveritself. - 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 todb.jsonare read withJSON.parse/JSON5.parse— a malformed file produces aSyntaxErrorprinted 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:
args()— parseprocess.argvwithnode:util.parseArgs. Handles--version(prints frompackage.json),--help, deprecated--watch(logs a warning), and the unknown-option error path. Returns{file, port, host, static}.- Validate the file path exists. If not, print red error and
process.exit(1). If empty, write{}into it. - Build the adapter chain: choose
JSONFileorDataFilebased onextname(file), wrap withNormalizedAdapter, wrap withObserver. ConstructLow<Data>(observer, {})and callawait db.read(). - Call
createApp(db, {logger: false, static: staticArr})andapp.listen(port, callback). The callback prints the colored startup banner with one of four kaomojis chosen at random. - If
NODE_ENV !== 'production': wire the Observer'sonReadStart/onReadEnd/onWriteStart/onWriteEndhooks and callchokidar.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 theSyntaxErroris printed in red.
src/app.ts
Exports createApp(db, options). Order of mounting:
sirv('public', {dev: !isProduction})— auto-mount the./publicdirectory.- For each path in
options.static: resolve to absolute (againstprocess.cwd()if not absolute) andapp.use(sirv(dir, {dev: !isProduction})). - CORS — installs
@tinyhttp/corsas a per-request middleware that reflects theAccess-Control-Request-Headersheader. Alsoapp.options('*', cors())for preflight. milliparsec.json()— JSON body parser.GET /— Eta render ofviews/index.htmlwith{data: db.data}.- List/show/create/update/patch/delete handlers (see route table in Section 1).
- Final
app.use('/:name', ...)— sendsres.locals['data']as JSON.404 Not Foundif the data is undefined;201if the request wasPOST;200otherwise.
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)— usesinflection.singularize/pluralizeto decide direction and the foreign-key field name (<singular>Id).nullifyForeignKey/deleteDependents— sweepdb.datafor 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, returnsfalse. - 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) for0.17compat, then defaults toeq.coerceValue(rawValue)— booleans/null/numbers/string fallback.setPathOp(out, path, op, value)— usesdot-prop'ssetPropertyto build nested objects from dotted paths. Theinoperator 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; otherwise1.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 theServiceclass and thebin.tswatcher. - Strict private fields.
Serviceuses#db. Adapters use#adapter/#data. Public surface is theAdapter<T>interface methods. - No exceptions for control flow. Failures (unknown resource, missing id) return
undefined; the route layer translatesundefinedinto404. - Type-narrowing guards.
isItem,isJSONObject,isWhereOperatorare explicit type guards used to keep the rest of the code well-typed. - Style. No semicolons (
oxfmtconfig). Single quotes. Strict-types-ontsconfig.
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
Appinstance. X-Total-Countheader (#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
/apiwould close #1710-class issues without requiring a reverse proxy. - Strict operator validation (#1731). Reject unknown operators inside nested
_whererather than silently descending. - Configurable embed key suffix (#1634). Allow
post_id/parentinstead of forcingxxxId. - 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-dataparsing.
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.tsfiles. 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.tsconstructs (NormalizedAdapterwrapping the file adapter). SkippingNormalizedAdaptermeans 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:
-
At the proxy layer. Caddy/nginx access logs are usually enough for development.
-
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 leavingjson-serveralone and adding capability around it. The package itself is intentionally not a platform.