Building a full-stack TypeScript app on Bun
2026-08-04
Bun made the runtime fast and the tooling unified. What it does not give you is an application architecture: routing, rendering, validation, and a typed line between your server and your frontend. This is the practical walkthrough of that layer with Nifra - a full-stack TypeScript framework built Bun-first - from scaffold to a deployable app.
Scaffold
bunx create-nifra taskboard --template site --framework react
cd taskboard && bun install
bun run devThe site template is the full-stack shape: file-based routes under routes/, a backend composition root at backend.ts, SSR with hydration, and a typed client wiring them together. Swap react for vue, solid, svelte, or preact - same framework underneath, same typed contract.
A typed backend in one file
// backend.ts - the composition root. Routes declared here are typed
// all the way into the frontend, with zero codegen.
import { server } from "@nifrajs/core/server"
import { t } from "@nifrajs/schema"
const tasks: Array<{ id: string; title: string; done: boolean }> = []
export const backend = server()
.get("/api/tasks", () => tasks)
.post(
"/api/tasks",
{ body: t.object({ title: t.string({ minLength: 1 }) }) },
(c) => {
const task = { id: crypto.randomUUID(), title: c.body.title, done: false }
tasks.push(task)
return task
},
)Two things are load-bearing here. The body schema is not documentation - it is enforced at the boundary, so the handler receives c.body already validated and typed, and a malformed request never reaches your code. And the whole app's route registry is carried in typeof backend, which is what makes the next part work.
Pages that cannot drift from the API
// routes/index.tsx - a page with a typed loader. The loader runs on the
// server, calls the backend in-process (no HTTP hop), and its return value
// flows to the component - typed against the backend contract.
export async function loader({ api }: LoaderArgs<typeof backend>) {
const res = await api.api.tasks.get()
return { tasks: res.data }
}
export default function Home(props: { data: LoaderData<typeof loader> }) {
return (
<ul>
{props.data.tasks.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
)
}The client is inferred from the server type - no OpenAPI generation step, no hand-written client to maintain. Which means:
// Rename `title` to `name` in backend.ts and the frontend fails to COMPILE:
//
// routes/index.tsx: Property 'title' does not exist on type
// '{ id: string; name: string; done: boolean }'
//
// That is the whole pitch of an inferred contract: drift is a build error,
// not a production incident.Validation is the default, not a discipline
Every route that accepts input declares a schema, in any Standard Schema library - Zod, Valibot, ArkType, or a hand-rolled validator. This is a security posture, not a convenience: unvalidated input on a public endpoint is mass assignment waiting to happen, so the framework makes the validated path the shortest one. Server functions take it further - JSON-only content type and same-origin checks are enforced for you (why).
The dev loop, including the AI half
bun run dev gives you HMR and SSR. The part most frameworks do not have: nifra check verifies your app - schema coverage, route hygiene, drift between contract and code - and returns structured output. If an AI agent writes part of your app (increasingly, it does), it reads those failures and fixes its own mistakes. Nifra's docs, examples, and API types are also a live MCP server your assistant can query mid-task.
Deploying - and the runtime escape hatch
nifra build produces the production server. On Bun, our published benchmarks put the framework at 101% of a hand-rolled Bun.serve baseline - the layer costs nothing measurable. And because runtimes are adapters, the same app deploys to Node, Deno, or edge workers unchanged if your infrastructure demands it. Numbers and methodology: benchmarks.
Where to go next
- The docs - routing, loaders, mutations, ISR, jobs, caching, auth.
- How Nifra compares to Next.js, Elysia, Hono, and Fastify.
- Agent setup - connect your AI assistant to the live docs endpoint.