HomeProjectsHackathonsEventsWorkBlogTimeline

Moving CRUD to the Frontend

4/30/2026
DatabasesLocal-firstPrismaDXOffline-first

Every backend I've written starts the same way. A posts table gets added to the schema, and before long I'm writing this:

POST /api/posts
GET  /api/posts
GET  /api/posts/:id
PUT  /api/posts/:id
DELETE /api/posts/:id

Then the same thing for comments. Then tasks. Then whatever model comes next. Each route gets its own handler, its own Prisma query, and its own JSON response. The logic is basically the same every time, but it still has to be written, tested, and maintained.

At some point I started wondering: what if the backend didn't need to know about any of this?

The idea

The traditional model has the backend sitting between the client and the database, fielding queries. But for a lot of apps, especially ones that benefit from offline support or fast local reads, the client is perfectly capable of querying data directly. The browser has storage. It can run queries. The only thing it can't do is be the source of truth.

So what if the backend's only job was syncing? Not handling GET /posts. Not building a response object. Just: here's what changed on the client, apply it to the server; here's what changed on the server, apply it to the client. A thin boundary instead of a full request handler for every model operation.

That's the idea behind Prisma IDB. It's a Prisma generator that creates a type-safe IndexedDB client from your existing schema. You write your queries directly in the frontend, against a local database, and a sync engine handles keeping it in sync with your backend.

What this actually looks like

Before Prisma IDB, querying IndexedDB for anything relational looks like this with the idb library:

const db = await openDB("MyDB", 1);

const posts = await db.getAllFromIndex("posts", "byAuthor", userId);

const result = [];
for (const post of posts) {
  if (!post.published) continue;
  const comments = await db.getAllFromIndex("comments", "byPost", post.id);
  result.push({ ...post, comments });
}

result.sort((a, b) => b.createdAt - a.createdAt);

Manual index lookups, manual joins, manual filtering, and zero type safety. If byAuthor is a typo, you find out at runtime.

With Prisma IDB, the same query is:

const posts = await idb.post.findMany({
  where: { authorId: userId, published: true },
  include: { comments: { orderBy: { createdAt: "desc" } } },
  orderBy: { createdAt: "desc" },
});

Same API as Prisma. Same types. If your schema changes, the generated client changes with it, and TypeScript tells you everywhere that breaks. No new API to learn if you're already using Prisma on the server.

Sync: the part that makes this actually work

A local database that never syncs isn't useful for most real apps. Prisma IDB has a sync engine that generates from the same schema. You wire up two endpoints (push and pull), drop a sync worker into your app, and it handles the rest: queuing mutations while offline, batching pushes when the network comes back, and pulling server changes down.

But I want to be upfront about what "thin backend" actually means here. You still need:

  • Auth. The sync endpoints need to know who's making requests.
  • The two sync routes themselves. They're not complex, but you do write them.

What you don't need is a handler for every model and every operation. No GET /posts, no PATCH /tasks/:id. That whole layer moves to the generated client. The backend just arbitrates sync.

When this makes sense

This works best when:

  • Your app has a meaningful offline use case
  • You want fast reads without waiting on a network round trip
  • Your data access patterns are per-user or per-team (the sync engine's ownership model assumes this)

It's not the right fit for apps with complex server-side aggregations, heavy write contention across many users on the same records, or cases where you need a public API that other clients consume. The generated client is for your app, not an API surface.

Where it came from

I built Prisma IDB because I kept running into this exact problem with MyFit, a fitness tracking app I've been building for a while. The data is inherently personal, most of it doesn't need a server round trip to display, and I wanted offline support without building a full sync system from scratch. I wanted a Prisma-compatible client I could drop into a SvelteKit app and have it just work, and it didn't exist, so I built it.

The project has grown since then. The sync engine, the outbox pattern, and the ownership DAG were not in the original version. That came from actually using it and running into the rough edges.

If you're building something where local-first would help, it's worth a look: prisma-idb.dev. The quick start takes about five minutes if you already have a Prisma project.