DEV Community

Prisma can now work in your browser, expo/react-native apps? - meet forge-orm

Prisma can now work in your browser, expo/react-native apps? - meet forge-orm

I love Prisma. I have shipped Prisma to production more times than I care to count. It changed how a lot of us write TypeScript against databases. But here's the thing nobody tells you about Prisma:

  • It doesn't run in the browser.
  • It doesn't run on Cloudflare Workers unless you buy the accelerator.
  • It runs on iOS through a weird JSI bridge I can never remember how to configure.
  • On Expo you eventually give up and use expo-sqlite raw.
  • On DuckDB? MSSQL? IndexedDB? MongoDB? Enjoy either a codegen dance or "unsupported."

So awhile ago I did a very dumb thing. I opened an editor and wrote:

import { createDb, f, model, tauriSqlDriver } from "forge-orm"

Then I spent couple of months making that import actually work. On Postgres. On MySQL. On SQLite. On MongoDB. On DuckDB. On MSSQL. On IndexedDB. On sqlite-wasm inside a browser tab with OPFS. On Tauri. On React Native. Same easy query API on all of them. Prisma-shape. No codegen. Type-safe end to end.

Let me show you what it does.

Pick any environment

Here's Node.js + SQLite:

import { createDb, f, model } from "forge-orm"

const User = model("users", {
  id: f.id(),
  email: f.string().unique(),
  name: f.string(),
})

const db = await createDb({
  schema: { user: User },
  url: "sqlite:./app.db",
})

await db.$migrate()

await db.user.create({
  data: { email: "ada@x.com", name: "Ada" }
})

const found = await db.user.findMany({
  where: { email: { endsWith: "@x.com" } },
  orderBy: { name: "asc" },
  take: 10,
})

Now, this is where it gets fun. Take that exact same code, and switch two lines:

import "forge-orm/indexeddb" // ← switch adapter

const db = await createDb({
  schema: { user: User },
  url: "idb:app", // ← switch URL
})

Everything else? Same. db.user.create(). db.user.findMany(). Same query shape. Same TypeScript inference. Same schema definition. It's now running in a Chrome tab, storing to IndexedDB, and you didn't write a single line of indexedDB.open().

But what if I want real SQL in the browser? It got you.

import { createDb, wasmSqliteDriver } from "forge-orm"

const worker = new Worker(
  new URL("forge-orm/wasm/worker", import.meta.url),
  { type: "module" }
)

const db = await createDb({
  schema: { user: User },
  driver: wasmSqliteDriver({
    worker,
    url: "opfs-sahpool:///app.sqlite", // real SQLite file in OPFS
  }),
})

sqlite-wasm running in a Web Worker, writing to a persistent OPFS file, exposing the same Prisma-shape query surface. No, I did not have a fun weekend making that work.

But my POS runs on Tauri!

I know. My POS runs on Tauri too. That's kind of why forge-orm exists.

import Database from "@tauri-apps/plugin-sql"
import { createDb, tauriSqlDriver } from "forge-orm"

const sqlite = await Database.load("sqlite:app.db")

const db = await createDb({
  schema: { user: User },
  driver: tauriSqlDriver(sqlite),
})

await db.$migrate()

Native SQLite through Tauri's sqlx bridge. Same query API. Wave your hand at your platform.

But my Cloudflare Worker only has D1! Also handled - libsql driver works over Turso + Neon's HTTP flavour, and D1 support is on the near roadmap.

There's also pgDriver, postgresJsDriver, mysql2Driver, mariadbDriver, planetscaleDriver, mongoDriver, duckdbDriver, mssqlDriver, expoSqliteDriver, opSqliteDriver. Ten adapters. One API.

Wait - you said no codegen?

Right, this is the part where Prisma users get suspicious. "No codegen means no types, right?" No. It means no prisma generate, no .prisma files, no build step. Types come from the schema you defined in TypeScript directly:

const Order = model("orders", {
  id: f.id(),
  amount: f.int(),
  status: f.enum(["pending", "paid", "refunded"]),
  tags: f.stringArray(),
  loc: f.geoPoint(), // yes, geo
  emb: f.vector(1536), // yes, vectors
  meta: f.json<{ notes: string }>(),
})

const schema = { order: Order }
const db = await createDb({ schema, url: "postgres://..." })

// db.order is fully typed from the schema you wrote 3 lines up
const paid = await db.order.findMany({
  where: {
    status: "paid", // ← "pending" | "paid" | "refunded" enum-narrowed
    amount: { gte: 100 },
    loc: {
      near: { lng: 3.3, lat: 6.5, withinMeters: 5000 }
    },
  },
  orderBy: { amount: "desc" },
  take: 20,
})

paid[0].meta.notes // typed as string, from the f.json<{ notes: string }>()

You didn't run a build step. You didn't wait for a codegen to finish. The types just are. TypeScript infers everything from f.string() / f.int() / f.enum([...]).

I actually really enjoy watching new users try to break this.

  • "But what about relations?" Yeah, those too. f.relation.one() / f.relation.many().
  • "But what about transactions?" db.$transaction(async tx => ...).
  • "But what about raw SQL?" db.$queryRaw SELECT ... - typed.

Real vector + geo queries, in the same API

This is the bit I'm proudest of. You know how Prisma has like six extensions to do vector search? forge-orm just:

const near = await db.docs.findMany({
  orderBy: {
    emb: { direction: "asc", nearTo: queryEmbedding }
  },
  take: 10,
})

near[0]._distance // typed number

And geo:

const nearby = await db.cafe.findMany({
  where: {
    location: {
      near: { lng, lat, withinMeters: 500 }
    }
  },
  orderBy: {
    location: { direction: "asc", nearTo: { lng, lat } }
  },
})

nearby[0]._distanceMeters

Same syntax on Postgres (with PostGIS), on DuckDB (with spatial), on SQLite (with SpatiaLite), on MongoDB (with $near), and on the browser IDB adapter with pure-JS ray-casting. Six backends, one API.

OK, sell me on the CLI

npx forge doctor

Hits your DB, checks every model, tells you what's missing:

✔ Postgres 16.2 · schema "public"
✔ Model "user" - table users OK
⚠ Model "order" - field status DB has TEXT, schema wants ENUM
✔ 24 indexes match
✖ Extension pgvector - NOT installed (required by orders.emb)
  Run: CREATE EXTENSION vector;
npx forge push

Applies drift. Non-destructive by default, --force-destructive for the scary ones.

npx forge diff

Dry-run of push. Shows exactly what SQL will run.

npx forge rollback

Undoes the last push.

For browser + Tauri, none of that matters - you call await db.$migrate() at boot and it does the same thing at runtime.

The real-world proof: Dallio

I'm not shipping a library and hoping someone tries it. I'm running this in production on my own product. Dallio is a POS/inventory SaaS for African SMBs. It has:

  • A backend on Node + hyper-express hitting MongoDB with forge-orm/mongo
  • A React frontend that persists an offline event log in forge-orm/indexeddb on web tabs
  • A Tauri desktop + mobile shell using forge-orm + tauriSqlDriver over @tauri-apps/plugin-sql

Same SyncEvent model definition across all three. Same query API. One schema file to change.

When a shopkeeper's phone loses internet mid-checkout, the sale writes to the local IndexedDB via forge-orm. When Wi-Fi comes back, the desktop till's forge-orm-backed SQLite catches up via a WebSocket. The backend's Mongo catches the same event. No adapter code. No translation layer. No two-database-schema sync bug.

Prisma cannot do this. Drizzle cannot do this. TypeORM… let's not talk about TypeORM.

Yes, I know what you're thinking: "Isn't this just another abstraction leak waiting to happen?" Every adapter has its own capability doctor - if you write a query the backend can't support (say, orderBy: nearTo on a Postgres without PostGIS), the doctor tells you at boot, not at 2am when a customer's till breaks.

"Why should I trust this over Drizzle?"

Drizzle is excellent. It's my second-favourite ORM after mine. Drizzle's schema-first + SQL-close model is beautiful. forge-orm targets a different point on the trade-off curve: I traded some of Drizzle's raw-SQL flexibility for Prisma's ergonomics + Drizzle's edge-friendliness + genuine cross-database portability. Pick the shape that fits your team.

Getting started

npm install forge-orm

Then 20 lines and you're querying:

import { createDb, f, model } from "forge-orm"

const Todo = model("todos", {
  id: f.id(),
  title: f.string(),
  done: f.boolean().default(false),
})

const db = await createDb({
  schema: { todo: Todo },
  url: "sqlite:./todos.db",
})

await db.$migrate()

const t = await db.todo.create({
  data: { title: "Try forge-orm" }
})

await db.todo.update({
  where: { id: t.id },
  data: { done: true }
})

console.log(await db.todo.findMany({
  where: { done: true }
}))

You now have a working ORM. Change one line to point it at IndexedDB, or Postgres, or Tauri SQLite. Nothing else in your code moves.

Links

If you ship a side project on forge-orm, I want to see it. Tag me: @iamJohnFash. Especially if you've got it running somewhere weird (Deno? Bun? A Raspberry Pi POS?). I collect these.

Comments

No comments yet. Start the discussion.