FrontierJS

Before & after

Twelve jobs you currently do three times.

None of these are hard. That is the problem — each one is easy enough that you just write it again in the database, again in the API, and again in the browser, and then keep three copies in agreement forever. Nothing fails when they drift. A user finds out.

The left column is a real stack, written the way you would actually write it. The right column is the same job when the schema is the thing everyone reads.

Band 01Rules that end up in three languagesThe database, the handler, and the component all have an opinion.

Ownership visibility

“Only the owner can read a lead.” One sentence in the standup, three implementations in the repo.

Before3 places · 3 languages
After1 line, on the Model

Before — and nothing errors when they disagree

Database

CREATE POLICY lead_owner ON leads
  FOR SELECT USING (owner_id = current_setting('app.user')::int);

API

router.get('/leads', requireAuth, async (req, res) => {
  const rows = await db.lead.findMany({
    where: { ownerId: req.user.id },  // don't forget this
  })
  res.json(rows)
})

UI

{lead.ownerId === session.user.id && (
  <EditButton />  // a third opinion, in a third language
)}

After

db/schema.lite

model Lead {
  ownerId Int
  (read, ownerId == auth().id)
}

The policy compiles into the query, so a route added next year is filtered too. The API returns 403 without a guard clause, and leads.can('update', lead) answers the same question in the browser — as an affordance, never a boundary.

Field validation

An email is an email in all three realms. You still declare it in all three, and the browser's copy is always the one that goes stale.

Before3 declarations · kept in sync by hand
After1 declaration, read by all three

Before

Database

ALTER TABLE leads
  ADD CONSTRAINT leads_email_unique UNIQUE (email),
  ADD CONSTRAINT leads_value_positive CHECK (value >= 0);

API

const LeadInput = z.object({
  name:  z.string().min(1).max(200).trim(),
  email: z.string().email().toLowerCase(),
  value: z.number().min(0),
})

UI

<input {...register('email', {
  required: true,
  pattern: /^\S+@\S+$/,   // the fourth spelling of "email"
})} />

After

db/schema.lite

model Lead {
  name  String (1, 200) 
  email String   
  value Float  (0)
}

The migration takes the constraints. The API's 400s are derived from the same rules. leads.fields.email is that rule in the browser — not a copy of it — and leads.make() renders an input the schema already describes.

Who may do what

Read is public, create needs a member, delete needs an admin. Now say that on every route, and again on every button.

Beforen routes × 4 verbs, plus the UI
After4 digits on the Model

Before

API

router.get   ('/leads',     leads.list)
router.post  ('/leads',     requireRole('member'), leads.create)
router.patch ('/leads/:id', requireRole('member'), leads.update)
router.delete('/leads/:id', requireRole('admin'),  leads.remove)
// repeat, per resource, forever

UI

const canDelete = user?.role === 'admin'
// the role names now live in two repos

After

db/schema.lite

model Lead {
  // read · create · update · delete
  (0.4.4.5)
}

Every service on that Model answers 401/403 at the right level without a guard, because the gate is under the data, not in front of the route. leads.can('delete') hides the button using the same four digits.

Band 02The wire between themShapes that are nobody's job until they are wrong.

Forms send strings

Every input value is a string, including <select> and type="number". And an empty box is '', which your nullable column does not consider empty.

Beforeper-field coercion, per form
Aftertwo options, once

Before

UI

onChange={e => setValue(parseFloat(e.target.value))}
// NaN when the box is empty

const payload = {
  ...form,
  value:    Number(form.value),
  nickname: form.nickname || null,   // remember this one
  notes:    form.notes    || null,   // and this one
}

Database

-- meanwhile, in the column you thought was empty
SELECT * FROM leads WHERE nickname IS NULL;
-- 0 rows. They are all ''.
-- and String? @unique accepted exactly one ''

After

web/src/routes/leads

const leads = createResource('leads')

// "42" → 42 by the schema's type, '' → null on a nullable column, and the
// schema's rules checked before it leaves the browser. Nothing is turned on:
// naming the model is the whole of it.

Runs coerce → blankToNull → validate, so validation judges what is actually sent. '' is never coerced to 0, and a string that is not cleanly the target type is left alone for the server to reject.

Pagination and the response shape

Every endpoint invents its own — a bare array here, { data, total } there — and every caller unwraps it differently.

Beforeper-endpoint convention
Afterone envelope, one owner

Before

API

router.get('/leads', async (req, res) => {
  const page  = Number(req.query.page ?? 1)
  const limit = Math.min(Number(req.query.limit ?? 20), 100)
  const [rows, total] = await Promise.all([…])
  res.json({ data: rows, total, page })   // this endpoint's shape
})

UI

const res  = await fetch('/api/leads?page=2')
const body = await res.json()
const rows = body.data ?? body.items ?? body   // hope

After

Any service, no code

GET /leads?$limit=20&$offset=40&$orderBy=createdAt

// $ is transport syntax and stops at the bridge:
ctx.query      // your filters
ctx.directives // { limit, offset, orderBy, select }

One envelope with kind: 'single' | 'list' as the discriminant — a list keeps it, a single unwraps. One module owns wrapping and unwrapping, so the browser never guesses.

Errors becoming status codes

Four libraries throw four shapes. Your error middleware grows an instanceof ladder, and anything it does not recognize is a 500 — including the 404 you meant.

Before1 ladder, edited per library
Aftera numeric status on your error

Before

API

app.use((err, req, res, next) => {
  if (err instanceof ZodError)        return res.status(400)…
  if (err instanceof PrismaKnownError) return res.status(409)…
  if (err.name === 'JsonWebTokenError') return res.status(401)…
  if (err instanceof HttpException)   return res.status(err.status)…
  res.status(500).json({ error: 'Internal' })  // everything else
})

After

Your package, anywhere

export class QuotaExceeded extends Error {
  status = 429            // that is the whole integration
}

throw new QuotaExceeded()   // raw route, service or plugin

One boundary turns a thrown value into a status, and it recognizes a numeric status without registration. Your errors need no dependency on the framework — and for a third-party error you cannot modify, register a mapper instead.

Band 03What one change costsThe real measure of a stack is what happens when a field moves.

Adding one field

A product manager adds “deal value”. Count the files.

Before6 edits · 4 of them silent if missed
After1 edit · fli db:migrate

Before — in order, and you must remember the order

Six files

1. migrations/0034_add_value.sql   // hand-written
2. schema.prisma                   // the model
3. lib/validators/lead.ts          // the zod schema
4. openapi.yaml                    // the documented shape
5. types/lead.d.ts                 // what the client believes
6. components/LeadForm.tsx         // the input and its rules

// forget 3 → it saves unvalidated
// forget 4 → the docs quietly lie
// forget 5 → the client is wrong at runtime only

After

db/schema.lite

model Lead {
  + value Float (0)
}

Then

$ fli db:migrate

The migration, the 400, the JSON Schema at /manifest, the TypeScript type, leads.fields.value and the input make() renders all move together — because none of them was ever a copy. There is no generated/ directory to review or re-apply edits to.

Band 04The parts you would otherwise go and installEach one is a vendor, a config file and a new vocabulary. Here they are the framework.

Live updates after a mutation

Every handler that writes must also remember to announce. The one that forgets is the bug you cannot reproduce.

Beforean emit per handler · rooms by hand
Afterone declared channel

Before

API

const lead = await db.lead.create({ data })
io.to(`org:${req.user.orgId}`).emit('lead:created', lead)

// …and in update. and in bulk import.
// and in the admin tool. and in the job.

UI

socket.on('lead:created', l => setLeads(p => [...p, l]))
socket.on('lead:updated', l => setLeads(p => p.map(…)))
// event names agreed by convention across two repos

After

api/src/services/leads.service.ts

export default createService({
  channel: 'leads',   // the declared broadcast target
})
// name and model come from the filename

A mutation is announced in one place — the same call that fans out to the event bus and to the channel. A handler cannot forget to publish, because publishing is not something a handler does.

Background jobs and cron

A queue usually arrives with a Redis to run, a worker process to deploy, and a second place your app config lives.

Before+1 service · +1 process · +1 config
Aftera table in the SQLite you already have

Before

Infra

# docker-compose.yml
redis:
  image: redis:7
worker:
  command: node dist/worker.js   # a second deploy target

API

const queue  = new Queue('email', { connection })
const worker = new Worker('email', handler, { connection })
// plus a separate node-cron in yet another process

After

jobs/send-email.job.ts

export default defineJob({
  handler: async (job) => { await mailer.send(job.data) },
  maxAttempts: 5,
  retryDelay: [60_000, 300_000],
})

Anywhere in the app

await app.jobs.dispatch('send-email', { to: lead.email })
app.jobs.schedule('digest', '0 9 * * *', sendDigest)

Same process, same file, same deploy. Retries, attempts and cron state are rows you can query — and job stats show up on /metrics because the queue is a plugin, not a neighbor.

Notifying a user

“Tell them in the app, and email them, and push it live.” Three code paths, and the copy is written three times so they drift immediately.

Before3 paths · 3 copies of the wording
After1 class · 1 call

Before

API

await db.notification.create({ data: { userId, title: 'Payment received', … } })
await sendgrid.send({ to: user.email, subject: 'Payment received', html: render(…) })
io.to(`user:${userId}`).emit('notification', { title: 'Payment received' })

// three wordings, one of which is now out of date
// and no per-user preference unless you add a fourth thing

After

api/src/notifications/PaymentReceived.ts

class PaymentReceived extends Notification {
  via(user)     { return user.notificationPreferences ?? ['inApp', 'email'] }
  toInApp(user) { return inApp().title('Payment received')… }
  toEmail(user) { return mail().subject('Payment received')… }
}

Anywhere

await app.notify(user, new PaymentReceived(payment))

One class holds the message, and via() decides the channels per user. The in-app record, the live event and the email fan out from the same object.

Transactional email

Your app is components. Your email is a different templating language, a different component set, and a plain-text version nobody maintains.

Beforea second UI stack for email
Afterthe same components, target email

Before

Email

<mjml><mj-body><mj-section><mj-column>
  <mj-text>Hi {{firstName}}</mj-text>
</mj-column></mj-section></mj-body></mjml>

// a second syntax, a second component library,
// and a text/plain part that is either absent
// or full of leftover markup

After

emails/Welcome.mesa

<Button href={url}>View order</Button>
// the same component language as your pages

Sending

const { html, text, subject } =
  await renderEmailFile('./emails/Welcome.mesa', { data })

Table-based markup and inlined CSS come out the other side, with the Outlook fallback handled and a real plain-text alternative derived from the same render — not a stripped copy of the HTML.

Calling someone else's API

Outbound fetch spreads until no one can answer “what does this service talk to, and with which credentials?”

Beforen call sites · n retry policies
Afterdeclared targets · one boundary

Before

API, scattered

await fetch(`https://api.hetzner.cloud/v1/servers/${id}`, {
  headers: { Authorization: `Bearer ${process.env.HETZNER_TOKEN}` },
})
// a token read at the call site, a timeout nobody set,
// a retry policy that exists in three variants

After

Declared once

target('provider:hetzner', {
  base: 'https://api.hetzner.cloud/v1',
  auth: { ref: 'HETZNER_TOKEN' },   // a reference, not the secret
})

Anywhere

const result = await app.conduit.send({
  target: 'provider:hetzner',
  method: 'GET',
  path:   '/servers/123',
})

Everything the app can reach is enumerable, credentials are references resolved at send time, and the result is { data } | { error } rather than a thrown fetch.

None of this was hard. That was the problem.

Every line in the left column is code a good developer writes correctly. The cost was never difficulty — it was that the same fact lived in three places, and keeping them in agreement is work that never finishes and never appears on a roadmap.

Before columns are illustrative of a typical modern stack, written the way you'd actually write it.Styled entirely by @frontierjs/css. Home →