AbsoluteJS

Choose Per Route: What Building With AbsoluteJS Is Actually Like

I've been shipping production apps on AbsoluteJS since 2025, and I work on the framework itself. This is what the day-to-day is really like, what to expect, and what it lets you build that you couldn't before.

The seams are where the time goes

Every web stack I've worked in has the same shape. There's a frontend framework, a server, something between them that carries data, something that builds it all, and something that deploys it. Each piece is fine on its own. The time goes at the joins.

You write a type on the server and write it again on the client. You pick React for the app and then find out the marketing team wants something lighter for the landing pages, and now there are two repos. Your dev server restarts and drops your database connection, your form state and your place in the flow. Your static site generator is a second, slightly different copy of your app.

AbsoluteJS is a full-stack TypeScript framework on Bun and Elysia. You can describe it as a list of features, but I think about it as a list of joins it removes. The biggest one is that choosing a frontend stops being a decision you make once per project and live with. You make it per route, and you can change your mind.

Over the past year I've built a production SaaS on it, a multi-tenant app builder, and an analytics platform that renders the same live data in four frameworks at once. I also spend my days in the framework's source. So this post is both the view from the inside and the view from someone who has to ship on it. Where something is rough, I'll say so.

A page is just a route

The first thing people coming from Next or SvelteKit look for is the app/ or routes/ folder. There isn't one. There's no file-system router.

A page in AbsoluteJS is an Elysia route that returns streamed HTML. That's the whole model. Here's a real server serving a React page, a Svelte page and an HTMX page side by side:

src/backend/server.tsts
import { Elysia } from 'elysia';
import { asset, handleHTMXPageRequest, networking, prepare } from '@absolutejs/absolute';
import { handleReactPageRequest } from '@absolutejs/absolute/react';
import { handleSveltePageRequest } from '@absolutejs/absolute/svelte';
import { Home } from '../frontend/react/pages/Home';
import type Pricing from '../frontend/svelte/pages/Pricing.svelte';

const { absolutejs, manifest } = await prepare();

export const server = new Elysia()
	.use(absolutejs)
	.get('/', () =>
		handleReactPageRequest({
			Page: Home,
			index: asset(manifest, 'HomeIndex'),
			props: { cssPath: asset(manifest, 'HomeCSS') }
		})
	)
	.get('/pricing', () =>
		handleSveltePageRequest<typeof Pricing>({
			indexPath: asset(manifest, 'PricingIndex'),
			pagePath: asset(manifest, 'Pricing'),
			props: { cssPath: asset(manifest, 'PricingCSS') }
		})
	)
	.get('/status', () => handleHTMXPageRequest(asset(manifest, 'Status')))
	.use(networking);

I was skeptical of this at first. File routing feels like a convenience you'd miss. In practice the explicit version has paid for itself. The route is right there with its auth guard, its params schema and its data loading, in the same Elysia chain as your API. There's no second routing system to learn, no special file names that change behavior, and no question about where middleware runs. When someone new joins a project, the server file is the map.

The other half of the model is the manifest. prepare() builds every framework directory you've configured and hands back a manifest that maps names to hashed files. You put a component in <framework>/pages/, and the build gives you keys named after it in PascalCase: Pricing for the server module, PricingIndex for the client hydration entry, PricingCSS for its stylesheet, and PricingCompiledCSS for a Vue component's own styles. Your handler looks them up with asset().

Framework
Dashboard
Not needed: the component is imported directly
DashboardIndex
/react/indexes/Dashboard.1nb1gfk.js
DashboardCSS
/styles/Dashboard.15at9hy.css

The hashes are examples. A real build generates them.

react/pages/Dashboard.tsxserver.ts
.get('/dashboard', () =>
  handleReactPageRequest({
    Page: Dashboard,
    index: asset(manifest, 'DashboardIndex'),
    props: { cssPath: asset(manifest, 'DashboardCSS') } // type-checked
  }))
Type a page name and pick a framework to see the manifest keys the build creates and the route that serves the page.

Two things about the manifest are worth knowing early. In dev, a missing key returns an empty string and the page is built on its first request, so you can add pages without restarting. In production, a missing key throws. That's deliberate: a typo in a key should fail the build, not ship a page with no JavaScript.

Props are typed against the page. If Home takes { cssPath: string; user: User } and you forget user, the route doesn't compile. Svelte pages get the same check: absolute typecheck generates real types for each Svelte component, so a missing prop, a wrong type or an unknown prop in server.ts fails just as it does for React. Add one rootDirs line to your tsconfig, which the command prints for you, and you see the same errors in your editor. Vue pages are checked by vue-tsc, and Angular pages read a typed request context.

Pick the frontend per route, then change your mind

This is the part that changed how I plan projects.

AbsoluteJS server-renders React, Svelte, Vue, Angular, plain HTML and HTMX, and each route chooses its own. They share one server, one build, one deploy and one set of API types. Ember support exists but is experimental and not publicly exported yet.

The clearest example I have is an analytics platform I built. One Elysia backend pushes live metrics over WebSocket and SSE. Four dashboards render the same numbers: one in React 19, one in Vue 3, one in Svelte 5, and one in HTMX over server-sent events, each on its own route. I built it to compare the frameworks fairly, under identical data and identical load. Normally that's four projects. Here it was four folders.

You probably won't build the same page four times. But the same property shows up in ordinary work:

  • The marketing pages are static HTML or HTMX and ship almost no JavaScript. The app behind the login is React.
  • A team that knows Vue can own one section of the product without a separate service.
  • An old Angular screen keeps running on its own route while you rebuild the rest, and it still shares the session and the API types.

Inside a page, you can go further with islands. An island is a component from one framework rendered inside a page from another, or inside a static HTML page, and hydrated on its own schedule. You register your islands once:

src/islands/registry.tsts
import { defineIslandRegistry } from '@absolutejs/absolute/islands';
import FieldTimer from '../frontend/svelte/components/FieldTimer.svelte';
import { PriceChart } from '../frontend/react/components/PriceChart';

export const islandRegistry = defineIslandRegistry({
	react: { PriceChart },
	svelte: { FieldTimer }
});

Then a plain HTML page can hold a React chart that hydrates only when the browser is idle. The <island> tag works too:

html/pages/Landing.htmlhtml
<absolute-island
	framework="react"
	component="PriceChart"
	hydrate="idle"
	props='{"symbol":"BTC"}'
></absolute-island>

Pointing the config at the registry, islands: { registry }, is all the setup a static page needs. The island is server-rendered into the HTML, and the build adds the small script that hydrates it.

Hydration is a prop, load, idle, visible or none, instead of a directive syntax. Islands that need to agree on something share a store, createIslandStore, and each framework reads it through its own adapter. A Svelte timer and a React chart on the same page see the same state. absolute islands --sizes lists every island by framework, hydration mode and bundle size, and flags pages that mix frameworks.

The part that makes this a real option, and not a demo, is that the choice is reversible. absolute add vue adds the dependencies, the config key and a starter page. absolute remove angular --prune takes the framework back out of the config and the dependencies, and leaves your source files alone. Choosing a framework for a route is about as expensive as choosing a library for a component.

Types cross the wire with no codegen

Your API is an Elysia app. You export its type, and the browser calls it through Eden Treaty:

src/backend/api.tsts
export const api = new Elysia({ name: 'api', prefix: '/api' })
	.get('/projects', () => db.projects.list())
	.post('/projects', ({ body }) => db.projects.create(body), {
		body: t.Object({ name: t.String({ minLength: 1 }) })
	});

export type Api = typeof api;
src/frontend/utils/edenTreaty.tsts
import { treaty } from '@elysia/eden';
import type { Api } from '../../backend/api';

export const server = treaty<Api>(location.origin);

const { data, error } = await server.api.projects.post({ name: 'Launch' });

Request bodies, params and responses are typed end to end, and there's no generation step to forget to run. On the production SaaS I work on, we deleted a hand-written API client and its hand-maintained response types. The types now come from the routes, so they can't drift.

This is also where I learned the framework's most important convention. Elysia's types flow through .use() composition, and long, deeply inferred chains can make TypeScript slow to the point of timing out. AbsoluteJS ships what it calls the Elysia composition contract, and eslint-plugin-absolute enforces it:

  • Give each independently owned surface its own named new Elysia({ name }). Don't build the admin app by extending the public one.
  • Prefer one shallow .use([auth, metrics]) over a long .use().use().use() chain. The lint rule fixes simple chains for you.
  • Keep exactly one installed copy of elysia and typebox. Two copies are the most common cause of types that suddenly fail to line up.

That last one used to cost me an afternoon each time. Now absolute doctor --fix finds the duplicate copies, rewrites your package overrides, removes the stale ones and reinstalls. The rule I hold myself to, and the reason this docs site bans as casts entirely, is simple: if a type doesn't flow, fix the flow. A cast hides the break, and Eden is only as good as the inference behind it.

One thing to expect: Eden turns ISO date strings in responses into Date objects. That's usually what you want, until a string comparison or a template like `${date}T00:00:00` quietly breaks. Normalize dates in one place, in your fetch helpers, and you won't think about it again.

Save the file and keep your place

The dev loop is where a framework either earns your trust or loses it, so here's exactly what happens when you save.

absolute dev doesn't bundle your app on every edit. It runs an unbundled module server that transpiles one file at a time and tracks which modules import which. Pages build lazily, on their first request, typically in about a second, so a large app boots fast. --eager restores the old build-everything-at-boot behavior if you want it.

Each framework then updates through its own path. Scroll through them and watch the count:

/react7React: State kept
  1. React

    Fast Refresh keeps component state. That needs a patched Bun build, because stock Bun ignores the Fast Refresh option in its transpiler (oven-sh/bun#32919). absolute bun-patch install installs it, with signature checks, and absolute dev picks it up. On stock Bun, React edits remount and you lose local state.

  2. Svelte

    Svelte uses its own native HMR runtime and keeps component state.

  3. Vue

    Vue uses its own native HMR runtime and carries setup state across the edit.

  4. Angular

    Angular swaps the component in place. Its state resets unless you opt in with preserveAcrossHmr, and if it has to fall back to a full reboot, a toast tells you why.

  5. HTML and HTMX

    HTML and HTMX pages are patched by diffing the DOM, so form input, scroll position and event listeners stay put.

  6. Server code

    Server code updates in place, per top-level statement. The process, the port, open requests, WebSockets, database pools and queues all carry across the edit. More on this below.

CSS hot-reloads too, both the global stylesheets you configure and the CSS a component imports. In my test, a stylesheet edit applied in about 20 ms with no reload.

The one I'd call the standout is backend HMR. When you edit server code, the running server updates in place, in milliseconds. When I changed a handler's response, the log said server.ts updated in 29ms, and the process ID was the same before and after. The process, the port, open requests, WebSockets, database pools, queues and in-memory state all carry across the edit. It works per top-level statement instead of per module: an unchanged const keeps the value it already created, and a side-effecting line like startQueue() runs again only if you changed it. If the new version throws, it rolls back.

If you've ever had a dev server re-run your entry file on every save, start a second queue worker, open a second pool and call a paid API at boot, you'll understand why this matters. It's also why there's one convention to follow: if something must exist exactly once, guard it on globalThis or clean it up with onHotDispose.

Failure is handled well too. If one build pass fails, whether it's a syntax error in a component or a bad CSS @import, dev mode keeps serving what built successfully and shows an overlay with the file, line and column. A browser tab you open afterward gets the last error replayed, so you're never staring at a blank page wondering what happened. Fix the error and the page updates on its own. Production builds still fail hard, which is what you want there.

Put together, the loop gets out of the way. You save, and the page or the server changes while whatever you were in the middle of stays put: the form you half filled in, the socket you had open, the job the queue was working through. After a while you stop thinking about the dev server at all, which is the best thing a dev server can do.

Checks fast enough that you actually run them

absolute typecheck is one command for the whole app. It runs the TypeScript checker, then adds vue-tsc, svelte-check and the Angular compiler only for the framework directories you've actually configured, all in parallel, with the output recolored so it reads as one report.

On Bun 1.4.3 and later it uses bun check, a port of TypeScript 7's checker that reads the same tsconfig.json and reports the same errors as tsc. On the framework's own repository:

absolute typecheckbun check on Bun 1.4.3+
0.6 s · 0.44 GB
tsc 5.9same files, same errors
7 to 10 s · about 1 GB
1,200 source files on the AbsoluteJS repository, measured on an Apple M4. tsc took 7 to 10 seconds and about 1 GB across runs; the grey bar shows the middle of that range.

That's 1,200 source files checked in about 0.6 seconds using 0.44 GB of memory, where tsc 5.9 took 7 to 10 seconds and about 1 GB, with identical errors reported.

The number matters less than what it does to your habits. A check that takes ten seconds or more runs in CI. A check that takes under a second runs every time you're about to commit, and in an agent's loop after every change. The same thinking shows up in absolute eslint, which is cached, and in absolute lint-proof, which records that lint passed locally so CI can verify it instead of linting again.

A tip from experience: after you change dependencies, run one cold typecheck. Warm incremental runs can hide a type graph that has quietly become expensive.

From laptop to production without rewriting the app

The parts of a project that usually need their own tool are built in, and they work from the same server you run in dev.

  • Prerendering runs your real server. You list routes in static: { routes }, or 'all' to crawl, and the build starts a temporary copy of your actual server and saves what it returns. There's no separate static pipeline that renders your app slightly differently. Add revalidate and stale pages are served instantly and re-rendered in the background. The one convention: wrap anything that shouldn't start during a build, such as queue workers, billing jobs and email, in if (!isPrerendering()).
  • The sitemap comes from your routes. The build reads your Elysia route registrations from the source and writes sitemap.xml. Set sitemap.baseUrl before you ship; it defaults to localhost.
  • Images are optimized out of the box. The optimizer mounts at /_absolute/image with sensible defaults, and there are <Image> components for React, Svelte and Vue. The images config block is only for tuning it. In one app, a 4.6 MB PNG came back as a 6.4 KB WebP at the size it was displayed, in about 1 ms once cached.
  • One binary. absolute compile produces a single executable with the build, the prerendered pages and the WebSocket handler inside. For containers, absolute prepare builds at image time and absolute start --prebuilt runs without writing to disk.
  • Installable, with offline support. One pwa key in the config generates the service worker, the web manifest and an offline fallback, and wires them into every framework's pages, islands included. Route and page code don't change. Turn on sync alongside @absolutejs/auth and the app keeps working offline, then syncs through IndexedDB when it reconnects. Turn on push for Web Push; only the public key ever reaches the browser, and page code never handles push credentials.

What one real app stopped needing

The production SaaS I work on started on a familiar stack, and we moved it to AbsoluteJS before its first deploy. Here's what it no longer needed afterward.

Before its first deploy, it dropped BullMQ, ioredis, Upstash Redis, two-second polling, a hand-written API client, Swagger, Sentry and a raw Twilio service.

  • The job queue moved to @absolutejs/queue on the Postgres database we already had, and Redis was deleted from the stack.
  • Polling for generation status became live updates over SSE with @absolutejs/sync.
  • The API client became Eden, and Swagger became the framework's built-in OpenAPI.
  • Error tracking moved to @absolutejs/errors with a browser beacon, stored in our own database.
  • SMS moved to @absolutejs/dispatch with a consent ledger, which closed a compliance gap the hand-rolled version had.

None of that changed the app's architecture. It's still one Elysia server, now with fewer vendors, fewer bills and fewer places for things to go wrong. Once staging was up, CI ran in a little over two minutes, and we deployed more than a hundred times in the following month.

The ecosystem is large, with more than 200 published @absolutejs packages covering auth, realtime sync, queues, AI, payments, observability, deployment and more. It's also young and moving fast. My advice is to adopt packages the way we did: one at a time, each one replacing something you were already maintaining.

Know these before day one

Convention or edgeWhat to do
There's no file-system routerRoutes are explicit Elysia handlers. The server file is your map.
Manifest keys are PascalCaseasset(manifest, 'FooIndex'). A missing key is empty in dev and throws in production.
Eden revives datesISO strings arrive as Date. Normalize them in your fetch helpers.
Keep one copy of Elysia and TypeBoxRun absolute doctor --fix. Prefer one shallow .use([a, b]).
Server modules re-run on editGuard pools and schedulers on globalThis, or clean up with onHotDispose.
Prerendering runs your real serverWrap queues, billing and email in if (!isPrerendering()).
The server binds localhost by defaultOn macOS that meant IPv6 ::1 only, so 127.0.0.1 didn't connect. In containers, set HOST=0.0.0.0 (or ABSOLUTE_HOST).
The README is behind the codeUse these docs and example/server.ts for the current API.
It's a fast-moving betaNew releases every few days. Pin exact versions and read the changelog.

Questions people ask

Is there file-based routing?

No. Routes are explicit Elysia handlers, and I've come to prefer it. A pageRouterPlugin exists in the source as a placeholder, but it isn't implemented.

Do I need the patched Bun?

Only if you want React to keep component state across edits. Everything else works on stock Bun 1.4.2 or later. absolute bun-patch install sets it up and absolute bun-patch remove takes it out.

Is it production-ready?

I run production work on it. It's also a beta that releases every few days. The current line is 0.20.0-beta.168, while npm's latest tag still points at the 0.19 line. Pin exact versions, read the changelog, and expect to upgrade often.

What's the license?

The core and most packages are BSL-1.1, with a grant that allows production use. You can build and run your own apps, internal tools and SaaS products on it, use it in commercial software you sell, and do client work with it. What you can't do is offer it as a hosted service that competes with a managed AbsoluteJS platform, sell it or a fork as a standalone product, or remove the attribution. The core converts to Apache-2.0 on May 29, 2030. Many adapters and some packages are Apache-2.0 or MIT already.

Can I bring an existing app?

absolute migrate --path ./old-app detects Next, Nuxt, Remix, SvelteKit, Astro, Create React App and Vite. It reports what maps where, including which of your dependencies have an @absolutejs equivalent. Because each route picks its framework, you can move one route at a time.

Does it work with coding agents?

That's been a design goal. The conventions are explicit, the composition contract is written for application authors and coding agents alike, the checks are fast enough to run after every change, and bun create absolutejs --agentic scaffolds an AGENTS.md with the project's rules for an agent to follow.

Pick a route. Pick a frontend.

Start with one page in the framework you know. Then add a second route in one you don't, and see how little changes.

bun create absolutejs