A fullstack framework · alpha
Nothing here asks you to learn a new paradigm. Each realm is shaped like something you have already used — and all of them read the same db/schema.lite, so a field you add shows up in the migration, the API's validation and the browser's form rules without being written three times.
app.jobs — on SQLite, so no Redis to run, back up or pay fornew to deploy, plus a language server for the schemaClosest thing · several things at once
Every part of this stack has a well-known relative. The difference is that they share a parent instead of a package registry: one schema, one hook pipeline, one error boundary, one CLI. You are not integrating six good libraries. You are using one thing that already agrees with itself.
↓
FrontierJS
One schema seeds all of it. Three realms, one vocabulary, fourteen packages that were written to fit.
Closest thing · Supabase, Firebase, PocketBase
A hosted platform gives you the first half: declare your data, get endpoints, auth, realtime and types. Then it hands you a client SDK and the browser starts again from zero — you re-type the validation, re-decide who may see the edit button, and hope the two halves stay in agreement.
Here both halves come from the same file. And there is no platform: it is a package in your node_modules, MIT, over a SQLite file you own.
400s from the field rulesresource.fields — the same rules, client-sideresource.can() — the model's own permissions, for hiding a buttonmake() — a form the schema describesClient-side permissions are an affordance, never a boundary — the server enforces regardless. That is the point of them living in one place.
Closest thing · Laravel, Rails, Django
A queue, a scheduler, mail, notifications, policies, migrations, generators, one CLI — the things that make a framework feel finished rather than started. The difference is where they stop. In all three the model layer ends at the network boundary, and the browser starts again from nothing.
Here the schema keeps going: the form knows the field rules and the button knows the permission, because both read the file the migration came from.
Everything above the line you can already get. The two below it are the argument.
Where every other framework stops
The server has a model, validation, policies, a hook pipeline. The browser has none of it, so it gets a second implementation of the same three facts — in a different language, kept in agreement by hand, and discovered to be wrong by a user.
FrontierJS puts the same four concepts on both sides of the wire, and the same schema under both.
createService({ model })around before after error@@gate · @@allow, compiled into SQLcreateResource('leads')around before after errorresource.fields · resource.validate()resource.can() — affordance, not boundary↑ one schema under both ↑
Closest thing · Rails' conventions
One layout for the whole application: a directory per realm, the schema above all of them, and the same six folders inside each. You do not decide where config goes, where tests go, or how the frontend finds the API — and because the convention is shared, a generator can write into all three at once.
fli make:resource lead writes the service and the routemodel Lead → service leads → db.lead, checked in three placesfli project:map prints what exists and what derives from whatmy-app/
├── db/ the seed — one schema, above both consumers
│ └── schema.lite
├── api/ API realm
│ ├── config/ src/ public/
│ └── test/ dist/ deploy/
└── web/ UI realm
├── config/ src/ public/
└── test/ dist/ deploy/
# the same six folders in every sub-project,
# so nothing has to be looked up twiceClosest thing · the stack you already assembled
Nothing on the left is a bad choice. They are all good — they just never met. Each brings a config file, an error shape, a notion of a user and its own idea of what your data looks like, and holding them in agreement is the job that never appears on a ticket.
// package.json
"prisma", // data
"zod", // validation, again
"casl", // authorization, again
"next-auth", // identity
"express", // transport
"socket.io", // realtime
"bullmq", // jobs
"node-cron", // schedule
"nodemailer", // mail
"react-email", // mail, again
"swagger-jsdoc", // the API, described again
"react", // ui
"react-hook-form", // the fields, again
"tailwindcss" // style
// + the glue between all fourteen// package.json
"@frontierjs/litestone", // Data
"@frontierjs/junction", // API
"@frontierjs/sierra", // UI
"@frontierjs/css" // style
// batteries, when you want them
"@frontierjs/auth"
"@frontierjs/caravan" // jobs + cron
"@frontierjs/notifications"
// the glue is the frameworkFewer dependencies is not the point. One vocabulary is the point — the count is just what that looks like from outside.
Closest thing · every stack you've integrated
Integration cost is not the install. It is that each library names the same fact differently, so you spend your afternoons translating — and every translation is a place the two can disagree without anything failing loudly.
One vocabulary is not a style preference. It is the difference between one fact and four copies of it.
Who is asking?
What does this record look like?
Who may read it?
What went wrong?
↓
All four, once
Closest thing · how you do it today
“Only the owner can read a lead” is one sentence, and most stacks make you say it in three languages: once in the database, once in the handler, once in the component that hides the row. Three chances to be right, and no error when they stop matching.
Declare it on the Model and it compiles into every query, under every caller — including the ones written after you left.
Today · database
CREATE POLICY lead_owner ON leads
FOR SELECT USING (owner_id = current_setting('app.user')::int);Today · API
router.get('/leads', requireAuth, async (req, res) => {
const rows = await db.lead.findMany({
where: { ownerId: req.user.id }, // don't forget this
})
})Today · UI
{lead.ownerId === session.user.id && (
<EditButton /> // a third opinion, in a third language
)}Here · once, on the Model
model Lead {
(read, ownerId == auth().id)
}
// the query is filtered, the API returns 403,
// and resource.can('read', lead) knows in the browser@frontierjs/css. Round one → · Current home page →