A JSONPlaceholder alternative where writes are saved
The same kinds of resources, but writes are saved, bad input is rejected and errors are available on demand.
Examples last run against the API on
JSONPlaceholder fakes its writes: a POST returns id: 101, but a GET for that id returns 404, and a DELETE leaves the record in place. If your app needs to read back what it just wrote, use https://api.testingapis.com/apisign in to see. It has the same kinds of resources (posts, comments, albums, photos, todos and users), and creates, updates and deletes are saved. It also rejects invalid input with a 400 and can fail any request on demand, so you can build and test the error states too. It's free and allows requests from any origin. Posts and todos need no key; the other resources need a free account's API key in the X-TestingApis-Key header.
Writes that are saved
This is what JSONPlaceholder does with a new post:
$ curl -X POST https://jsonplaceholder.typicode.com/posts \
-H "Content-Type: application/json" \
-d '{"title":"hello","body":"world","userId":1}'
{ "title": "hello", "body": "world", "userId": 1, "id": 101 }
$ curl -i https://jsonplaceholder.typicode.com/posts/101
HTTP/1.1 404 Not FoundThe same steps here: create a post, read it back, edit it, delete it and check it's gone.
$ curl -X POST https://api.testingapis.com/api/Posts \
-H "Content-Type: application/json" \
-d '{"userId":101,"title":"Hello from the guide","body":"Checking that writes persist."}'
{"message":"Post created successfully","data":{"id":16,"userId":101,"title":"Hello from the guide","body":"Checking that writes persist.","tags":[],"publishedAt":"2026-10-07T02:58:58Z","likes":0}}
$ curl https://api.testingapis.com/api/Posts/16
{"id":16,"userId":101,"title":"Hello from the guide","body":"Checking that writes persist.","tags":[],"publishedAt":"2026-10-07T02:58:58Z","likes":0}
$ curl -X PUT https://api.testingapis.com/api/Posts/16 \
-H "Content-Type: application/json" \
-d '{"userId":101,"title":"Edited title","body":"Checking that writes persist."}'
{"message":"Post updated successfully","data":{"id":16,"userId":101,"title":"Edited title",...}}
$ curl -X DELETE https://api.testingapis.com/api/Posts/16
{"message":"Post deleted successfully"}
$ curl -i https://api.testingapis.com/api/Posts/16
HTTP/1.1 404 Not Found
{"status":404,"error":"Not Found","message":"Post with ID 16 does not exist."}Route mapping
| JSONPlaceholder | testingapis.com | Records |
|---|---|---|
/posts | /api/posts | 15 (JSONPlaceholder: 100) |
/posts/1/comments | /api/posts/1/comments | Comments on post 1 |
/comments | /api/commentsdocs | 50 (500) |
/albums | /api/albumsdocs | 10 (100) |
/albums/1/photos | /api/albums/1/photossign in to see | 5 per album |
/photos | /api/photosdocs | 50 (5,000) |
/todos | /api/todos | 30 (200) |
/users | /api/peopledocs | 20 (10), with address, geo and company |
/users/1/posts | /api/posts/user/101 | User ids start at 101 |
Paths are case-insensitive: /api/posts and /api/Posts are the same. There are fewer records than on JSONPlaceholder, but each one has more fields. Posts have tags, likes and a publish date, todos have a priority and a due date, and people have a nested address and company. The API documentation lists 13 more resource types, from products and carts to books, movies and countries.
/api/posts/1Bad input gets an error
JSONPlaceholder accepts any body, even an empty one. Here, a missing required field returns 400 with a message, so the error branch of your form has something real to show:
$ curl -i -X POST https://jsonplaceholder.typicode.com/posts -H "Content-Type: application/json" -d '{}'
HTTP/1.1 201 Created
{ "id": 101 }
$ curl -i -X POST https://api.testingapis.com/api/Posts -H "Content-Type: application/json" -d '{"title":""}'
HTTP/1.1 400 Bad Request
{"status":400,"error":"Bad Request","message":"Missing required field: title"}Errors and slow responses on demand
Add ?error=500 to any request to make it fail with that status, or ?delay=3 to answer three seconds late. Together, ?delay=2&error=503 shows a loading state followed by an error. The error responses guide covers timeouts, retries and rate limits.
/api/todos/1?error=503Filtering, sorting and paging
Both APIs filter lists by field, as in ?userId=101, and both sort, search and page. The parameter names differ:
| JSONPlaceholder | testingapis.com | |
|---|---|---|
| Filter | ?userId=1 | ?userId=101 |
| Page | ?_page=2&_limit=10 | ?page=2&limit=10 |
| Sort | ?_sort=id&_order=desc | ?sort=id&order=desc |
| Search | ?q=word | ?q=word |
| Total count | X-Total-Count | X-Total-Count, X-Total-Pages |
Switching an app over
Reads return the record directly, as JSONPlaceholder does. Writes wrap it with a message, as { message, data }, and user ids start at 101. Those are the two changes most apps need:
// Before
const BASE = "https://jsonplaceholder.typicode.com";
const post = await (await fetch(`${BASE}/posts/1`)).json();
const created = await (await fetch(`${BASE}/posts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: 1, title: "Hi", body: "There" }),
})).json();
// After
const BASE = "https://api.testingapis.com/api";
const post = await (await fetch(`${BASE}/posts/1`)).json();
const { data: created } = await (await fetch(`${BASE}/posts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: 101, title: "Hi", body: "There" }),
})).json();When JSONPlaceholder is the better choice
- You need large lists, such as 5,000 photos to test virtual scrolling.
- You're following a tutorial that uses JSONPlaceholder's exact ids and fields.
- You only read data, and never need a write to show up afterwards.
To go further, the CRUD testing lesson walks through checking that each create, update and delete really happened.