Tutorial

Build a todo app before your backend exists

Turn a two-field schema into a hosted, persistent CRUD API, then connect it to a working React client. No database migrations, controllers, or deployment pipeline required.

APIMock team8 min read

A todo app is small, but its backend still needs real behavior: records must persist, updates must be validated, and filters must return the right data. APIMock's Data API gives you that behavior from a schema, so frontend work can start while the production API is still being designed.

Instant CRUD

Five generated routes from one resource.

Persistent state

Changes survive across requests and clients.

Repeatable demos

Reset live data back to known seeds.

01

Create the todos resource

Sign in to APIMock, open your workspace, and navigate to Endpoints from the sidebar. Click the Create resource button to open the creation wizard, and create a resource with the name todos.

Add these fields:

FieldTypeOptions
titlestringrequired
completedbooleanrequired
APIMock adds id, createdAt, and updatedAt automatically. IDs are sequential within the resource, so they are predictable in tests and easy to read in request logs.
02

Give every run a useful starting point

Click on any of your new generated todos endpoints to open the editor. Select theData tab, toggle to Initial Data, and add two records. Initial records keep stable IDs and are restored whenever you reset the resource.

Code
Record id: 1
{
  "title": "Connect the API",
  "completed": true
}

Record id: 2
{
  "title": "Build the todo interface",
  "completed": false
}

This is useful beyond demos. A QA run can create, update, and delete anything it needs, then reset the resource to the same baseline before the next run.

03

CRUD Endpoints are automatically live

Your five generated CRUD endpoints are automatically live and organized inside a todosfolder in your endpoint tree. They are available under your workspace's base URL:

Code
GET    /todos       List todos
POST   /todos       Create a todo
GET    /todos/:id   Fetch one todo
PUT    /todos/:id   Update a todo
DELETE /todos/:id   Delete a todo

Click the ... menu on the todos folder in the endpoint list and selectView OpenAPI Spec to copy your exact base URL and inspect or download the generated OpenAPI document. You can also import that document directly into Postman, Insomnia, or Swagger UI.

Use mock data only. Resources are development sandboxes, not storage for real credentials or personal information.
04

Connect the browser client

Add a small API module to your frontend and replace the placeholder host with the base URL shown in APIMock. CORS allows browser requests by default.

src/api.js
const API_URL = "https://<your-workspace>.<host>/todos";

export async function listTodos(show = "all") {
  const filter = show === "all" ? "" : `&filter[completed]=${show === "done"}`;
  const response = await fetch(`${API_URL}?limit=100${filter}`);

  if (!response.ok) throw new Error("Could not load todos");
  return (await response.json()).data;
}

export async function addTodo(title) {
  const response = await fetch(API_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ title, completed: false }),
  });

  if (!response.ok) throw new Error("Could not create todo");
  return (await response.json()).data;
}

export async function toggleTodo(todo) {
  const response = await fetch(`${API_URL}/${todo.id}`, {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ ...todo, completed: !todo.completed }),
  });

  if (!response.ok) throw new Error("Could not update todo");
  return (await response.json()).data;
}

export async function deleteTodo(id) {
  const response = await fetch(`${API_URL}/${id}`, { method: "DELETE" });
  if (!response.ok) throw new Error("Could not delete todo");
}

Every successful write returns a { data } envelope. Lists return { data, meta }, where meta contains pagination totals. Errors have a stable { error: { code, message } } shape, so your UI can handle failures consistently.

Sorting and filtering only accept fields declared on the resource. Automatic metadata such as createdAt cannot be passed to sort; declare your own date or position field if the interface needs explicit server-side ordering.
05

Wire it into React

The component below loads todos, creates new records, toggles completion, deletes records, and lets the server filter the list. Styling is intentionally omitted so you can drop the behavior into your own design system.

src/TodoApp.jsx
import { useEffect, useState } from "react";
import { addTodo, deleteTodo, listTodos, toggleTodo } from "./api";

export default function TodoApp() {
  const [todos, setTodos] = useState([]);
  const [title, setTitle] = useState("");
  const [filter, setFilter] = useState("all");

  async function refresh(nextFilter = filter) {
    setTodos(await listTodos(nextFilter));
  }

  useEffect(() => {
    refresh();
  }, [filter]);

  async function submit(event) {
    event.preventDefault();
    if (!title.trim()) return;
    await addTodo(title.trim());
    setTitle("");
    await refresh();
  }

  return (
    <main>
      <h1>Today</h1>
      <form onSubmit={submit}>
        <input
          value={title}
          onChange={(event) => setTitle(event.target.value)}
          placeholder="What needs doing?"
        />
        <button>Add</button>
      </form>

      <nav>
        {["all", "open", "done"].map((value) => (
          <button key={value} onClick={() => setFilter(value)}>
            {value}
          </button>
        ))}
      </nav>

      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={async () => {
                await toggleTodo(todo);
                await refresh();
              }}
            />
            <span>{todo.title}</span>
            <button onClick={async () => {
              await deleteTodo(todo.id);
              await refresh();
            }}>
              Delete
            </button>
          </li>
        ))}
      </ul>
    </main>
  );
}

Notice that the filter is not implemented with Array.filter in the browser. The client sends filter[completed]=true or filter[completed]=false, which exercises the same server-side request flow your production client will use.

06

Test the behavior, then reset

Open the app in two browser windows. Create a todo in one and refresh the other. The record is still there because resources are stateful—not a fixed response masquerading as CRUD.

  • Create a todo and confirm the API assigns the next integer ID.
  • Toggle it and verify updatedAt changes.
  • Filter between open and completed records.
  • Delete a record and confirm a later GET returns 404.
  • Open the Inspector to review the requests your client sent.

When you are done, click the ... menu on the todos folder in the endpoint list and selectReset Data (or use the **Reset to initial data** button inside the **Data** tab of the endpoint editor). APIMock deletes the live records, restores IDs 1 and 2 from the seed, and rewinds the counter. Your next run starts clean.

Keep building

Your frontend now has a real API contract

Add priorities, due dates, or an enum status to the resource as the product evolves. When the production backend is ready, keep the contract and swap the base URL.