There's a moment in most TypeScript backend projects when the data layer stops being one thing.
Maybe you started on Postgres, then a feature shipped where audit events made more sense as a flat Mongo document. Maybe your mobile app needs to work on the train, so you added SQLite in the browser. Maybe a "find me the three nearest locations" endpoint forced you to drop into raw ST_Distance_Sphere() because your ORM didn't know what a geo point was. Maybe you added embeddings for an AI search box and now half your query path is a $queryRaw with a hand-rolled vector cosine clause.
Each of those moments seems small in isolation. Stacked, they're how a typed data layer becomes four data layers — a Prisma client over here, a Mongoose schema over there, Dexie wrapping IndexedDB, and a folder full of raw SQL strings nobody wants to touch. Each layer has its own concept of "model," its own migration story, its own way of describing a where clause. The code in your services is suddenly half plumbing.
I built Forge because every other TypeScript ORM I tried forced me to pick one surface and treat the rest as a foreign country.
(https://www.npmjs.com/package/forge-orm)
The constraint that produced it
I was working on a multi-tenant SaaS that had to:
- Run Postgres for transactional state
- Use Mongo for high-volume nested documents that didn't want a schema
- Work fully offline in the browser on a Tauri-packaged client, including a point-of-sale flow that took payments without a network
- Geocode every location and answer nearest-neighbor queries on them
- Eventually add embeddings so an operator could type "blue cotton tee" and find products without exact keyword matches
Pick any two of those and the existing ORMs work. Pick three and you start writing glue. Pick all five and you spend more time keeping four data layers in sync than building features.
Prisma covers Postgres beautifully and treats Mongo as a second-class adapter. It has no browser story at all. Geo is raw SQL. Vector is raw SQL.
Drizzle is the cleanest TS-native SQL builder I've ever used — but it's SQL-only. No Mongo. No browser. Geo and vector escape into hand-written fragments.
Kysely has the best types of any query builder, but it's also SQL-only and intentionally not an ORM. You write joins. No relations sugar. No browser.
TypeORM has Mongo support, but the types degrade to any so often the TypeScript story is barely real, and decorators don't compose with edge runtimes.
Mongoose handles Mongo well. It does not handle Postgres at all.
Dexie handles IndexedDB. It does not handle anything else.
So you stack them. And you write a small in-house abstraction layer on top so service code doesn't have to know which one it's hitting. And that layer eats a month every year just keeping up.
Forge is what happens when you decide that's not acceptable and try to put one query language under all of it.
Video: https://youtu.be/FpOaneoCzpw
What you actually get
*One vocabulary across six dialects
*
Forge has adapters for Postgres, MySQL, SQLite, Mongo, DuckDB, and MSSQL. The same query method names work on all six:
// Postgres
await db.User.findMany({ where: { tier: "pro" }, limit: 20 })
// Mongo
await db.User.findMany({ where: { tier: "pro" }, limit: 20 })
// DuckDB (analytical)
await db.User.findMany({ where: { tier: "pro" }, limit: 20 })
That's not "compatible-ish." Those are byte-identical service-layer calls. The adapter underneath translates where, limit, orderBy, select, include, groupBy, upsert, transaction, and the rest into the dialect's native form. A Mongo $or becomes a SQL OR. A SQL NOT IN becomes Mongo's $nin. JSON path access becomes ->> on Postgres and dotted-key access on Mongo.
The point isn't that you'll switch databases (you mostly won't). The point is that when a feature lands that needs the other database, you don't relearn an ORM. You point a different db at the same schema and write the same code.
The browser is a first-class target
This is the part I haven't seen elsewhere.
Forge ships a browser adapter built on sqlite-wasm + OPFS, running in a Web Worker. It mounts as opfs:/your-db.sqlite or opfs-sahpool:/... for synchronous-access-handle mode, or :memory: for tests. There are Vite, Next.js, and Webpack plugins that handle the wasm loading without you wiring it.
// In the browser. Same API as the server.
const db = await createBrowserDb({ schema, url: "opfs:/dallio.sqlite" })
await db.Invoice.create({ data: { number: "INV-3308", total: 4250 } })
const drafts = await db.Invoice.findMany({ where: { status: "draft" } })
The query you wrote for your server's Postgres works in the user's browser against SQLite. You stop maintaining two data layers because the same model is the same model. Offline drafts, offline POS sales, offline product catalogs — all queryable with the same findMany your service code uses, no IndexedDB wrapper, no Dexie schema duplicated from your Postgres schema.
For a Tauri app or a PWA that needs to keep working when the network drops, this collapses an entire architectural sub-project into a config flag.
Geo, vector, and JSON path are typed
const Place = f.model("places", {
id: f.id(),
name: f.string(),
location: f.geoPoint({ srid: 4326 }),
embedding: f.vector(1536),
details: f.json<{ openingHours: string[]; tags: string[] }>(),
})
// Nearest 10 places to a point — Postgres uses PostGIS, MySQL uses
// ST_Distance_Sphere, Mongo uses $near. Same query call.
const nearby = await db.Place.findMany({
where: { location: { nearTo: { point: [3.42, 6.45], withinMeters: 5000 } } },
limit: 10,
})
// Vector similarity — same vocabulary as geo
const similar = await db.Place.findMany({
where: { embedding: { nearTo: { vector: queryEmbedding, take: 5 } } },
})
// Typed JSON path — no string fragments
const cafes = await db.Place.findMany({
where: { "details.tags": { contains: "cafe" } },
})
None of those require raw SQL. None require a separate vector library. None require a PostGIS adapter package. The results are typed — nearby[0].location is a [number, number], similar[0].embedding is number[], details.tags is string[].
If you've ever written a cosine_similarity() SQL fragment by hand or tried to thread PostGIS through Prisma raw queries, you know what the absence of this costs.
No binary engine, no codegen step
Forge is pure TypeScript. There is no Rust query engine that needs to be downloaded per platform, no prisma generate step in your CI, no .prisma schema file written in a custom DSL.
Your schema is a TS file. Models are values. Importing them gives you typed query methods. That's the whole setup.
Cold start on Lambda or Vercel Functions is under 50ms, the same as Drizzle and Kysely, an order of magnitude faster than Prisma's historical baseline. Edge runtimes (Workers, Vercel Edge, Bun, Deno) work natively because nothing about Forge requires Node-specific APIs.
Drift detection that works in production
const report = await db.$migrate()
// {
// alteredColumns: [{ table: "users", column: "phone", action: "added" }],
// pending: [{ table: "users", column: "legacy_id", action: "drop" }],
// }
db.$migrate() walks the live schema against the model definitions. Safe drift (an added column, a new index) gets applied automatically. Destructive drift (a column that no longer exists in the model) is surfaced under pending for your migration tool to handle deliberately. You can opt out with db.$migrate({ alter: false }).
db.$diff(driver, { schema }) returns the same report without applying anything. db.$doctor() runs a live probe — checks extensions, indexes, encoding, latency — and tells you what's wrong. Both work in the browser too, against the user's OPFS SQLite.
In practice this means the day a new feature ships with two new columns, you don't need to ship a migration file. You can let $migrate() apply the additions on boot and ship a migration for the destructive part next sprint. Or, if you're more conservative, you can run $diff() in CI and refuse to deploy when drift is detected. Both flows work.
Atomic upsert that actually is atomic
await scopedDb.User.upsert({
where: { email },
create: { email, name, tier: "free" },
update: { lastSeenAt: now },
})
That call compiles to INSERT ... ON CONFLICT DO UPDATE on Postgres, INSERT ... ON DUPLICATE KEY UPDATE on MySQL, MERGE on MSSQL,
findOneAndUpdate({ upsert: true }) on Mongo, and the SQLite equivalent. One round trip. No find-then-branch race condition. No accidental double-insert under concurrency.
I mention this because it's not glamorous, but every ORM that doesn't do it correctly produces a class of intermittent production bugs you can never reproduce locally and your tracing tool blames on "user double-clicked." Forge does it correctly across all six dialects.
What Forge is not
I'll be direct about this because honest tradeoffs are part of why anyone trusts a recommendation.
There is no Studio. Prisma Studio is a category-defining piece of tooling. db.$doctor is CLI-only and doesn't browse data. If your team includes people who want to click around tables in a GUI, Forge will frustrate them until a Studio ships.
There is no SaaS company behind it. That has good and bad sides. No paid Accelerate/Pulse/Optimize tier means the OSS roadmap isn't shaped by upsell pressure. No SaaS company also means no support contract, no enterprise SLA, no Salesforce conference booth, no team of dedicated maintainers if I get hit by a bus.
The community is tiny. If you Google a Forge error, you'll mostly find the README. Stack Overflow won't help. There are no YouTube tutorials. Onboarding a junior engineer takes longer because the answer to "how do I do X" is "read the source," not "watch this 20-minute video."
Depth versus breadth. Drizzle has gone deeper into Postgres-specific edge cases than Forge has. Mongoose has gone deeper into Mongo's aggregation pipeline. Forge covers six dialects well, but a dialect-specific ORM will always go deeper into its one dialect. If you need Postgres logical replication consumer slots or Mongo's exact change-stream resume semantics surfaced as a first-class API, you'll write that integration yourself in Forge.
It's young. First release was 2024. 2.5.3 is the current version. The shape is stable, the test suite is large (the test file count crossed 470 in 2.5.3, mostly per-dialect parity), but five-year battle scars accumulate things you can't get faster. Expect a few rough edges.
If those are dealbreakers, Drizzle or Prisma is your answer. That's a real choice, not a consolation prize.
When Forge is the right answer
Pick Forge when:
- Your data already lives in more than one place. Postgres + Mongo. Postgres + SQLite (mobile sync). DuckDB for analytics over a transactional Postgres. MSSQL for a legacy system plus Postgres for the new one. Forge collapses the abstraction tax of running multiple ORMs.
- You're building an offline-first or local-first app. Tauri desktop apps, Capacitor mobile apps, PWAs that need to work on flaky connections. Forge's browser adapter means you maintain one data layer instead of one server data layer plus one client IndexedDB layer plus the sync code between them.
- Geo or vector is on your roadmap. Even if you don't need them today, building them on raw SQL fragments is a permanent tax. Forge surfaces both as typed primitives.
- You care about cold starts. Lambda, Vercel Functions, Cloudflare Workers — Forge is pure TS, no binary, no codegen runtime. Drizzle and Kysely match it; Prisma still doesn't.
- You like writing code more than configuration. The schema is a TS file. The migration story is "the live schema and the models disagree, here's what changed." There's no DSL, no codegen step, no separate migration CLI to learn.
Don't pick Forge when:
- You have a senior Postgres-only team that already loves Drizzle. Forge wouldn't make their day better.
- You need Studio and your stakeholders aren't going to budge.
- You're shipping inside a company that requires a vendor with a support contract.
Getting started
npm install forge-orm@^2.5.3
Define a model file:
// schema.ts
import { f } from "forge-orm"
export const User = f.model("users", {
id: f.id(),
email: f.string({ unique: true }),
name: f.string(),
tier: f.enum(["free", "pro", "enterprise"]).default("free"),
createdAt: f.timestamp().defaultNow(),
})
export const schema = { User }
Connect from your server:
import { createPostgresDb } from "forge-orm/postgres"
import { schema } from "./schema"
export const db = createPostgresDb({ schema, url: process.env.DATABASE_URL! })
await db.$migrate() // applies safe drift on boot
Or from the browser:
import { createBrowserDb } from "forge-orm/browser"
import { schema } from "./schema"
export const db = await createBrowserDb({ schema, url: "opfs:/app.sqlite" })
await db.$migrate()
The same db.User.findMany({ where: { tier: "pro" } }) works in both. That is the entire setup.
A statement about TypeScript data layers in 2026
The data layer should follow your data, not the other way around. When a feature pushes you toward a different database — analytical, document, offline — the ORM should come with you, not force you to maintain a second one.
Most TypeScript ORMs were built when "the database" meant Postgres or maybe MySQL. They optimize for the depth of that single relationship. That's an honest tradeoff and it produces excellent tools for the case where it's correct.
But TypeScript apps in 2026 aren't single-database anymore. They run partially in the browser. They store transactional data in one engine and analytical data in another. They pull embeddings out of vector indexes and locations out of geo indexes. They sync some state offline and reconcile it when the network returns.
Forge is what an ORM looks like if you take that reality as the starting point.
It is not the safe choice. The safe choice is Prisma. The cool choice is Drizzle. The purist choice is Kysely.
Forge is the choice when none of those covers your data's actual shape, and you'd rather pay the cost of a younger tool than the cost of running four mature ones in parallel for the rest of the project's life.
If that's where you are, forge-orm@^2.5.3 (https://www.npmjs.com/package/forge-orm) is one npm install away.
Top comments (0)