A full-stack React framework built on top of Bun. It has file-based routing, API routes, server functions, and SSR/static rendering. All of that runs in one process instead of five different tools combined.
I was building a SaaS called autopermit using Next.js and Vercel. Every click in the dashboard felt slow, and the deployments took too long. Before that, I created a small blog on Astro, and I appreciated how fast the frontend felt. So, I had this idea in my head: combine Astro's frontend speed with Next.js's backend, without putting two frameworks together.
I didn't know how to make that happen until I started exploring Bun. It already comes with a bundler, a runtime, a package manager, and native SQLite/Postgres drivers. That’s when I realized I didn’t need to connect two bulky frameworks. I needed one framework based on Bun's foundations instead of adding more tools around it.
That's x. I'm 18, and this is the first framework I've tried to design. Most of the work was me figuring things out as I went, not following a specific plan.
I'd never designed a framework before this, so I spent time reading how Next.js, Astro, and Remix set up their routing and rendering before writing any code. The bundler was the part that really gave me trouble in getting the client/server boundary right. I wanted to make sure a DATABASE_URL or STRIPE_SECRET_KEY didn't accidentally end up in the browser bundle. It took a few rewrites to get that right.
I kept a TASKS.md file in the repo the whole time. That way, I had something to refer to when I lost track of what "done" meant. It's still in the history if you want to see how the scope changed. There were also a few nights when I was tired and pushed through code without thinking it through completely. I had to go back later and clean some of that up.
Deploy speed. I push a commit, refresh the site, and it’s already live. That never happened to me on Next.js or Vercel, where deploys took a long time. I’m also pleased with how the split feels in practice. It’s Astro-like on the frontend and Next.js-like on the backend, both running through Bun instead of on top of it.
This started as an experiment to learn how frameworks actually work underneath, and to solve a real problem I had. It's a solo project — use it for learning, poking around, or build something small on it, but treat it as curiosity software, not a production dependency.
bun create thexjs-app@latest my-app
cd my-app
bun run devThe scaffolder is feature-based: it asks which features you want (tailwind, auth, content, hooks, shadcn) — pick nothing for a blank home page, then the dev server runs at http://localhost:3000. Pass features non-interactively with flags like --tailwind --auth; --no-install and --no-git skip the install/init steps.
In maintenance mode (author busy with the college), releases, reviews, and merges happen on a best-effort weekly cadence — see ROADMAP.md and CONTRIBUTING.md.
packages/core file-based router, SSR renderer, middleware
packages/cli x dev / x build / x start
packages/env typed env variable validation
packages/auth credentials + OAuth2/GitHub auth, sessions, CSRF
packages/hooks client-side hook helpers (useSession, usePageData, ...)
packages/adapter-vercel Vercel Build Output API adapter
packages/create-thexjs-app the scaffolder (bun create thexjs-app@latest)
packages/mcp MCP server so AI agents get x's conventions right
examples/default minimal starter
examples/basic pages, API routes, auth, dashboard
examples/landing the docs site
examples/blog blog w/ content collections
examples/saas saas dashboard demo
A typical project looks like:
my-app/
x.config.ts
src/
pages/ # file-based routes
layouts/ # nested layouts (_layout.tsx)
api/ # REST endpoints
actions/ # server functions
content/ # markdown collections
Every .tsx file under src/pages/ becomes a route based on its path:
pages/index.tsx -> /
pages/about.tsx -> /about
pages/blog/[slug].tsx -> /blog/:slug
pages/_404.tsx -> catch-all 404
Dynamic segments ([slug]) show up in the loader via params:
export async function loader({ params }: LoaderArgs) {
const post = await getPost(params.slug);
return { title: post.title, content: post.content };
}
export default function BlogPost({ loaderData }: RouteProps) {
return <h1>{String(loaderData?.title)}</h1>;
}Pages are server-rendered by default. Add export const mode = "static" to prerender at build time instead:
export const mode = "static";
export default function About() {
return <h1>About</h1>;
}SSR pages run their loader on every request:
export async function loader({ request }: LoaderArgs) {
const res = await fetch("https://api.example.com/products");
return { products: await res.json() };
}src/layouts/main.tsx is the root layout wrapping every page. Drop a _layout.tsx inside any pages folder to nest a layout for that section:
pages/
_layout.tsx
blog/
_layout.tsx # only wraps /blog/*
index.tsx
Files under src/api/ become REST endpoints, running in the same process as everything else:
export async function GET(req: Request) {
const users = await db.query("SELECT * FROM users").all();
return Response.json(users);
}Write a normal async function in src/actions/, call it from the browser without hand-writing a REST endpoint:
// src/actions/greet.ts
export async function greet(name: string) {
return `Hello, ${name}!`;
}At runtime, registerServerFunctions() stores the real function (closure, db access, everything) in an in-memory route table (ACTION_ROUTES). A single Bun.serve() handler (getServerFunctionHandler) matches incoming POST /__x/actions/<path>/<fnName> requests against that table, runs a CSRF check, parses the JSON body as args, calls the real function, and returns the JSON result (or a 500 if it throws).
The client never sees that function body. When an island or component imports from src/actions/*, the bundler swaps the import for a generated fetch wrapper instead of bundling the real file:
// what actually ships to the browser for `import { greet } from "../actions/greet"`
export async function greet(...args: unknown[]): Promise<unknown> {
const res = await fetch("/__x/actions/greet/greet", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(args),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}That swap happens in a Bun.build() plugin (actionsRewritePlugin) that intercepts any import resolving to a registered action file and rewrites its contents before the client bundler ever reads the real source so the db calls and secrets inside greet.ts are structurally excluded from the client bundle, not just hidden by convention. You can still import the same function directly inside a loader or API route, since those run server-side in the same process.
Getting this boundary right (making sure DATABASE_URL or STRIPE_SECRET_KEY never end up in a browser bundle) was the hardest part of building this. Two things enforce it:
- Build-time interception: the
actionsRewritePluginabove means server-function bodies are never even parsed by the client-target bundler. - A leak scanner as a safety net: after each island bundle is built,
assertNoEnvLeakage()runs a regex scan (not an AST pass worth being precise about that) over the final bundled JS text, looking forprocess.env.X,Bun.env.X, orimport.meta.env.X. Anything not prefixedTHEXJS_PUBLIC_fails the build with anEnvLeakageError. It's a simple, fast check on the compiled output rather than a structural understanding of the code, so it catches the common cases but isn't meant to be un-fool-able (e.g.Bun["e" + "nv"]would slip past a naive regex) the interception above is what actually makes the leak impossible, the scanner just double-checks it.
Island bundling itself happens in memory: for a route with islands, a scratch hydration entry file is written to a temp dir, bundled with Bun.build({ target: "browser" }) (React/ReactDOM external), scanned, and the temp dir is deleted immediately after nothing lands in the project tree.
This boundary is the part I rewrote the most times. Early on I only had the scanner, no interception, and a bug in the regex was the only thing standing between a real project and a leaked API key. Moving the real protection to build time (so the secret-holding code is never even read by the client bundler) and keeping the scanner as a second check, not the only check, is the version I'd actually trust in a project with real users.
Markdown + frontmatter in, pages out:
const posts = scanContent("posts");
const html = renderMarkdown(post.body);Every <a> tag gets SPA-style client-side navigation and hover prefetch automatically, no router setup required. Opt out per-link with data-no-nav / data-no-prefetch, or use the typed <Link> wrapper. A built-in /_x/image proxy streams allow-listed remote images through your own origin so a strict img-src 'self' CSP still works with external images:
import { Link, createImageProxyHandler } from "@thexjs/core";
<Link href="/docs">Docs</Link>
const imageProxy = createImageProxyHandler({ remoteHosts: ["cdn.example.com"] });@thexjs/core ships an <Image> component that gets the parts of
next/image that earn their keep without a build-time optimization pipeline:
- Responsive
srcset/sizes— remote images render width variants (&w=320…&w=3840) through the/_x/imageproxy. - Auto remote-routing — an absolute
srcwhose host is inx.config.tsimages.remoteHostsis rewritten to the proxy automatically; local/relativesrcpasses through untouched. priority— skipsloading="lazy", addsfetchpriority="high"for LCP images.fill— absolute-positionedobject-fit: coverfor hero/card layouts.placeholder="blur"— optionalblurDataURLshown as a CSS background until the real image loads (no JS island needed).- Dev warnings — missing width/height (CLS),
fill+dimensions conflict, and remote hosts absent fromimages.remoteHosts(would 403).
import { Image } from "@thexjs/core";
<Image
src="https://cdn.example.com/team.jpg"
alt="The team"
width={1600}
height={900}
priority
sizes="(max-width: 768px) 100vw, 50vw"
/>alt is required. Format <picture> sources and resizing are reserved for a
future optimization pipeline — the proxy currently passes images through
unresized but validates w/q hints so the component API is stable.
Drop a _middleware.ts in any pages folder it runs for that folder and everything under it. Useful for auth checks, redirects, logging.
@thexjs/auth adds credentials (username/password) and OAuth2 (including a GitHub preset) sign-in with one defineAuth() call. Passwords are hashed with Argon2 via Bun.password, session tokens are HMAC'd at rest and revocable, sessions live in SQLite or Postgres through the data layer, and auth POST endpoints run the core CSRF module automatically. Mount it on a single catch-all API route:
import { defineAuth, createSQLiteSessionStore } from "@thexjs/auth";
export const auth = defineAuth({
secret: process.env.AUTH_SECRET!,
store: createSQLiteSessionStore(),
providers: [{ id: "github", name: "GitHub", type: "oauth", clientId: "...", clientSecret: "..." }],
});
// api/auth/[...auth].ts — forward GET/POST to auth.handleRequest(req)See packages/auth/README.md for the endpoint map (/api/auth/signin/:id, /callback/:id, /signout, /session) and getSession() usage.
Built-in SQLite (connectSQLite, zero config, good for dev) and Postgres (connectPostgres, connection pooling, for production) with versioned migrations for both.
x build # -> .x/ (static HTML + server bundle + manifest)
x start # run the production server from .x/Deploys to anywhere Bun runs Docker, Fly.io, Railway, a VPS.
Everything's optional, x.config.ts just lets you override defaults:
export default defineConfig({
pagesDir: "src/pages",
layoutsDir: "src/layouts",
apiDir: "src/api",
actionsDir: "src/actions",
contentDir: "content",
port: 3000,
});Most people building on x, including experienced devs, lean on AI coding agents to get things done — and a general-purpose agent will confidently write Next.js/Remix/TanStack Start syntax that looks right but isn't x's. Two things ground agents in x's actual conventions instead:
@thexjs/mcp— an MCP server withlist_topics/get_docs/search_docs/scaffold_filetools. Every project scaffolded withbun create thexjs-app@latestwires it up automatically (.mcp.jsonfor Claude Code,.cursor/mcp.jsonfor Cursor). See packages/mcp/README.md.AGENTS.md(+ aCLAUDE.mdpointer) — shipped in every scaffolded project as a fallback for agents without MCP access, covering routing, loaders, server functions, and the env-var prefix convention.
Packages follow semver and are pre-1.0 in spirit: the public API is still settling, so expect breaking changes between minor releases. Pin exact versions if stability matters to you.
- Releases are managed with Changesets. Every PR touching a
packages/*package adds a changeset (bun changeset), which bumps versions and writes changelogs when merged tomain. - The release GitHub Action (
publishonmain) creates a versioning PR and publishes to npm on merge. - Currently published:
@thexjs/core(1.8.x),@thexjs/cli(1.1.x),@thexjs/env(1.1.x),@thexjs/auth(3.0.x),@thexjs/hooks(0.2.x),@thexjs/adapter-vercel(1.0.x),create-thexjs-app(1.2.x).
- Unstable API: everything above is subject to change. Treat pre-1.0 packages (and any untagged minor in 1.x/3.x) as potentially breaking.
- Bun-only: the framework runs on Bun (runtime, bundler, package manager). There is no Node.js target.
- Content collections and the data layer (SQLite/Postgres migrations) work but have the least test coverage in the framework; the image proxy and server-actions runtime are covered, the build toolchain is exercised via the Vercel adapter and CLI integration tests.
- Route-level test coverage is newest: the request pipeline, server
functions, env validation, and the Vercel adapter all gained tests in the
latest pass; remaining gaps are documented in
ROADMAP.md.
- Docs: https://thexjs.abdelkabir.me/docs
- Repo: https://github.com/thexjs/x
- Security: SECURITY.md
- Threat model: THREAT_MODEL.md
- Data retention & deletion: DATA_POLICIES.md
- Support & versioning: SUPPORT.md, VERSIONING.md
- Benchmarks & SLOs: BENCHMARKS.md
- Contributing: CONTRIBUTING.md
- Install:
bun create thexjs-app@latest
MIT licensed. Not production-ready yet this is a solo learning project, feedback and issues are welcome.