Getting started
Arachne is a TypeScript framework for Bun. One project layout builds a pre-rendered static site, a server-rendered app with an API, or an API on its own. Pages are JSX compiled to direct DOM updates, driven by signals.
This page takes you from an empty directory to a production build in about ten minutes. The framework guide explains how the pieces fit together afterwards.
Requirements #
- Bun (opens in a new tab) 1.3 or newer (
bun --version). - macOS, Linux or WSL.
Create a project #
bunx @arachnejs/kit create my-site --template static
cd my-site
bun install
bun run dev # http://localhost:3000, reloads as you editThe CLI is arachne, from @arachnejs/kit. Inside
a project, bunx arachne … runs it (for example bunx arachne routes). Outside
one, use bunx @arachnejs/kit …: the unscoped arachne package on npm is
unrelated to this project.
| Template | What you get |
|---|---|
static | A small blog: layout, build-time data, per-page titles, sitemap. Deploy dist/ anywhere. |
server | Sign-up, email verification, sign-in and password reset; per-user notes with access rules; typed API client; OpenAPI docs; MCP tools. |
api | A projects API: API tokens, validation, pagination, file uploads, CORS, rate limits, OpenAPI. |
The rest of this page builds a smaller app by hand, so you can see what each file does.
Build one by hand #
1. The package #
In an empty directory hello/, create package.json:
{
"name": "hello",
"private": true,
"type": "module",
"scripts": {
"dev": "arachne dev",
"build": "arachne build",
"start": "arachne start",
"preview": "arachne preview"
},
"dependencies": {
"@arachnejs/kit": "^0.1.0",
"@arachnejs/render": "^0.1.0",
"@arachnejs/router": "^0.1.0",
"@arachnejs/schema": "^0.1.0",
"@arachnejs/server": "^0.1.0",
"@arachnejs/signals": "^0.1.0"
}
}And tsconfig.json, so your editor type-checks the JSX:
{
"compilerOptions": {
"target": "ES2024",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"types": ["bun"],
"strict": true,
"jsx": "react-jsx",
"jsxImportSource": "@arachnejs/render",
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["app", "arachne.config.ts"]
}Then run bun install.
2. Pages #
Pages are a route table in app/routes.tsx. A route with children is a
layout: it renders the matched child as props.children.
import { Link, type RouteDefinition, type RouteProps } from "@arachnejs/router";
import { signal } from "@arachnejs/signals";
function Layout(props: RouteProps) {
return (
<>
<nav>
<Link href="/">Home</Link> · <Link href="/about">About</Link>
</nav>
<main>{props.children}</main>
</>
);
}
function Home() {
const count = signal(0);
return (
<>
<h1>Hello, Arachne</h1>
<button type="button" onClick={() => count.set(count() + 1)}>
Clicked {count()} times
</button>
</>
);
}
function About() {
return <p>Rendered on the server, then hydrated in the browser.</p>;
}
export const routes: RouteDefinition[] = [
{
path: "/",
component: Layout,
children: [
{ path: "", component: Home, head: { title: "Home" } },
{ path: "about", component: About, head: { title: "About" } },
],
},
];bun run devOpen <http://localhost:3000>. The HTML arrives already rendered; the
browser then takes it over (hydration). The button updates just its own text
node: components run once, and only the expressions that read count()
run again. Links switch pages without a full reload.
3. Page data and an API route #
app/server.ts holds everything that runs only on the server: API routes,
middleware, and loaders, which provide data for a page. Loaders are keyed
by route pattern.
import { defineServer } from "@arachnejs/kit";
import { s } from "@arachnejs/schema";
import { route } from "@arachnejs/server";
const greet = route({
method: "GET",
path: "/api/greet",
query: s.object({ name: s.string({ min: 1, max: 40 }) }),
handler: (ctx) => ({ message: `Hello, ${ctx.query.name}!` }),
});
export default defineServer({
routes: [greet],
loaders: {
"/": () => ({ now: new Date().toISOString() }),
},
});Creating this file switches the app from static to server mode, and the dev
server restarts. The query is validated before the handler runs, and
ctx.query.name is typed as a string:
curl "localhost:3000/api/greet?name=Ada"
# {"message":"Hello, Ada!"}
curl "localhost:3000/api/greet"
# 422 {"error":{"status":422,"code":"validation_failed","message":"Request validation failed",
# "issues":[{"location":"query","path":"name","message":"expected string"}]}}The page receives its loader's result as props.data. The first request
runs the loader on the server; client-side navigations fetch the same data
as JSON. Replace Home with:
function Home(props: RouteProps) {
const data = props.data as { now: string };
const count = signal(0);
const reply = signal("");
const greet = async (event: SubmitEvent) => {
event.preventDefault();
const name = new FormData(event.target as HTMLFormElement).get("name");
const res = await fetch(`/api/greet?name=${encodeURIComponent(String(name))}`);
const body = await res.json();
reply.set(res.ok ? body.message : body.error.message);
};
return (
<>
<h1>Hello, Arachne</h1>
<p>Rendered at {data.now}.</p>
<button type="button" onClick={() => count.set(count() + 1)}>
Clicked {count()} times
</button>
<form onSubmit={greet}>
<input name="name" placeholder="Your name" />
<button type="submit">Greet</button>
</form>
<p>{reply()}</p>
</>
);
}For a typed client instead of fetch, see
OpenAPI and the typed client.
4. Build and run #
bun run build # dist/client (assets) + dist/server/index.js
bun run start # PORT=8080 bun run start to pick a portThe server bundle includes the @arachnejs/* code. Your app's other
dependencies stay in node_modules, so deploy with
bun install --production next to dist/.
Static instead #
Set the mode in arachne.config.ts to pre-render every page:
import { defineConfig } from "@arachnejs/kit";
export default defineConfig({
mode: "static",
siteUrl: "https://example.com", // for sitemap.xml
});bun run build then writes dist/**/index.html, a JSON file of loader
data for each page that has a loader, 404.html and sitemap.xml. Loaders run once, at build
time. API routes are not served, because there is no server. Dynamic
routes such as blog/:slug list their pages with paths in
app/server.ts; see kit: Server.
bun run preview serves the result locally.
This website is an Arachne static build: each page is rendered from the repository's Markdown at build time.
Where next #
- Framework guide: the three modes, how packages fit, security defaults.
- kit: configuration, dev server, CLI.
- router: layouts, lazy routes, head tags, links.
- server: routes, validation, uploads, OpenAPI.
- auth and acl: accounts and permissions.
- UI components: 330+ accessible components.