00. Getting started
This page builds one Avelon application from an empty folder: a route file, a controller, a validated write, a view, and a test. Every command and file on this page was run against this repository with Next 16.2.12.
Read 01-thesis.md for why the framework is shaped this way. Read this page to see it run.
What you need
| Tool | Version | Why |
|---|---|---|
| Bun | 1.3.14 | The pinned toolchain. 11-runtime.md has the runtime matrix. |
| Next | 16.2.12 | @avelonjs/next is the v1 adapter and targets the App Router. |
| React | 19.2.0 | Server components render the views the kernel returns. |
A database is optional. The app on this page runs with drivers: {}.
Run an example first
git clone https://github.com/yannelli/avelon.git
cd avelon
bun install
bun run dev:gazette
Open http://localhost:3010. Isle Gazette is a newspaper with authors, issues, subscribers, and a queued listener. Two more apps run from the same clone:
| Command | App | Port |
|---|---|---|
bun run dev:gazette | Isle Gazette, the reference application | 3010 |
bun run dev:docs | These docs, as an Avelon app | 3011 |
bun run dev:starter | SaaS starter: auth, dashboard, settings | 3012 |
Where a new app lives
There is no create-avelon yet, and bun add @avelonjs/next does not resolve today: the 0.1.0 tarballs on npm declare their sibling packages as workspace:*, which bun cannot resolve outside this monorepo. Until a release replaces those specs, start a new app as a workspace member of your clone. 16-releases.md covers how versions are cut.
mkdir -p examples/hello/{app/Http/Controllers,app/Http/Requests,routes,src,tests}
mkdir -p examples/hello/resources/views/{layouts,pages}
Nine files hold the application:
| File | What lives there |
|---|---|
routes/web.ts | Every route in the application |
app/Http/Controllers/GreetingController.ts | One method per route |
app/Http/Requests/GreetRequest.ts | Validation for a write |
resources/views/tokens.ts | Opaque view names the controller returns |
resources/views/pages/greeting.tsx | The React component behind a token |
resources/views/registry.ts | Tokens mapped to components |
resources/views/layouts/AppLayout.tsx | The root layout the generated router re-exports |
src/boot.ts | Config, routes, kernel |
src/boot-next.ts | Adapter attachment, once per process |
The application
package.json. The generate script runs reeve route:sync, which writes the Next router from routes/web.ts.
{
"name": "@avelonjs/hello",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"generate": "reeve route:sync",
"dev": "bun run generate && bun run --bun next dev --port 3013",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@avelonjs/core": "workspace:*",
"@avelonjs/next": "workspace:*",
"next": "16.2.12",
"react": "19.2.0",
"react-dom": "19.2.0"
},
"devDependencies": {
"@avelonjs/cli": "workspace:*",
"@types/bun": "1.3.14",
"@types/node": "^26.4.0",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"typescript": "5.9.3"
}
}
tsconfig.json. The @/* path is what generated pages import through.
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": ["bun"],
"jsx": "react-jsx",
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": [
"src",
"app",
"routes",
"resources",
"framework",
"next.config.ts",
"instrumentation.ts",
"next-env.d.ts"
]
}
next.config.ts. The Avelon packages ship TypeScript source, so Next transpiles them.
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import type { NextConfig } from 'next'
const root = path.dirname(fileURLToPath(import.meta.url))
const config: NextConfig = {
outputFileTracingRoot: path.join(root, '../..'),
transpilePackages: ['@avelonjs/core', '@avelonjs/next'],
}
export default config
routes/web.ts. One file answers "what URLs does this application serve".
import { Route } from '@avelonjs/next'
import { GreetingController } from '../app/Http/Controllers/GreetingController'
/** Every route in the application, in one file. */
export function defineWebRoutes(): void {
Route.get('/', GreetingController, 'index').name('home')
Route.get('/greet/{name}', GreetingController, 'show').name('greet.show')
}
defineWebRoutes()
app/Http/Controllers/GreetingController.ts. A controller method returns a result; it does not render. view() names a token and hands over props.
import { view, type HttpRequest } from '@avelonjs/core'
import { GreetingView } from '../../../resources/views/tokens'
export class GreetingController {
index() {
return view(GreetingView, { name: 'world' })
}
show(request: HttpRequest) {
return view(GreetingView, { name: request.params.name ?? 'world' })
}
}
resources/views/tokens.ts.
/** Opaque view token. Core never inspects it. */
export const GreetingView = 'greeting'
resources/views/pages/greeting.tsx.
export function GreetingPage({ name }: { readonly name: string }) {
return (
<main>
<h1>Hello, {name}.</h1>
</main>
)
}
resources/views/registry.ts. The adapter resolves tokens through this map, which is why app/ holds no React import.
import type { ViewRegistry } from '@avelonjs/next'
import { GreetingPage } from './pages/greeting'
/** View tokens mapped to React components. */
export const views: ViewRegistry = {
greeting: (props) => GreetingPage(props as never),
}
resources/views/layouts/AppLayout.tsx. reeve route:sync writes app/(web)/layout.tsx as a re-export of default, metadata, and viewport from this file, so export all three.
import type { Metadata, Viewport } from 'next'
import type { ReactNode } from 'react'
import { ensureBooted } from '@/src/boot-next'
export const metadata: Metadata = { title: 'Hello Avelon' }
export const viewport: Viewport = { width: 'device-width', initialScale: 1 }
export default async function AppLayout({ children }: { readonly children?: ReactNode }) {
await ensureBooted()
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
src/boot.ts. Config, routes, kernel. Driver constructors belong here or in avelon.config.ts, and nowhere under app/.
import { createKernel, defineConfig } from '@avelonjs/core'
import { Route } from '@avelonjs/next'
import { defineWebRoutes } from '../routes/web'
export function boot() {
defineConfig({ name: 'hello', drivers: {} })
Route.reset()
defineWebRoutes()
return createKernel()
}
src/boot-next.ts. Next evaluates modules per process; this attaches the adapter once and hands the view registry over.
import { NextAdapter, Route, getAdapter, setRuntimeBoot } from '@avelonjs/next'
import { views } from '../resources/views/registry'
import { boot } from './boot'
let booting: Promise<void> | undefined
export async function ensureBooted(): Promise<void> {
setRuntimeBoot(ensureBooted)
if (booting !== undefined) {
await booting
return
}
booting = (async () => {
try {
getAdapter()
return
} catch {
// First request in this process.
}
const kernel = boot()
const adapter = new NextAdapter({ root: process.cwd() })
adapter.attach(Route.manifest(), { kernel, views })
})()
await booting
}
setRuntimeBoot(ensureBooted)
instrumentation.ts. Next calls register at process start.
export async function register(): Promise<void> {
if (process.env.NEXT_RUNTIME === 'edge') return
const { ensureBooted } = await import('./src/boot-next')
await ensureBooted()
}
Generate the router and run it
bun install
bun run --filter @avelonjs/hello dev
generate prints what it wrote:
Mounted 2 route(s). Wrote 4 file(s).
Those four files are app/(web)/page.tsx, app/(web)/greet/[name]/page.tsx, app/(web)/layout.tsx, and framework/routing/manifest.generated.ts. They are generated output: change routes/web.ts and run generate again. An edit under app/(web)/ is discarded by the next sync, and reeve bailiff fails a framework/routing/*.generated.ts file whose generator banner is gone.
Open http://localhost:3013 for Hello, world. and http://localhost:3013/greet/ada for Hello, ada.
Add a write
Writes are served as generated server actions. Add the route:
Route.post('/greet', GreetingController, 'store').name('greet.store')
Validate the input in a Request, app/Http/Requests/GreetRequest.ts:
import { defineRequest, type RequestSchema } from '@avelonjs/core'
export interface GreetInput {
name: string
}
const schema: RequestSchema<GreetInput> = {
parse(input: unknown): GreetInput {
const record = input as Record<string, unknown> | null
const name = typeof record?.name === 'string' ? record.name.trim() : ''
if (name === '') {
throw { issues: [{ path: ['name'], message: 'A name is required.' }] }
}
return { name }
},
}
/** Validates the greeting form. */
export const GreetRequest = defineRequest<GreetInput>({ schema })
Add the action to the controller:
async store(request: HttpRequest) {
const data = await GreetRequest.validate(request)
return redirect(`/greet/${encodeURIComponent(data.name)}`)
}
bun run generate now writes framework/routing/actions.generated.ts with a greet_store export. A server-component <form> takes an action returning void, so wrap it in resources/views/actions.ts:
'use server'
import { greet_store } from '../../framework/routing/actions.generated'
/** Server-component forms take an action returning `void`. The redirect propagates by throwing. */
export async function greetStore(formData: FormData): Promise<void> {
await greet_store(formData)
}
Then post to it from the page:
import { greetStore } from '../actions'
export function GreetingPage({ name }: { readonly name: string }) {
return (
<main>
<h1>Hello, {name}.</h1>
<form action={greetStore}>
<input name="name" aria-label="Name" defaultValue="ada" />
<button type="submit">Greet</button>
</form>
</main>
)
}
Submitting posts to the action, which runs GreetRequest and redirects to /greet/ada. To render validation errors instead of discarding them, read the action result with useActionState in a client component; examples/gazette/resources/views/form.tsx is a working one.
Test it without a browser
Tests dispatch through the kernel, so they need no server and no Next.
import { expect, test } from 'bun:test'
import { createKernel, defineConfig, type HttpRequest } from '@avelonjs/core'
import { Route } from '@avelonjs/next'
import { defineWebRoutes } from '../routes/web'
function request(overrides: Partial<HttpRequest> = {}): HttpRequest {
return {
method: 'GET',
url: 'http://localhost/',
params: {},
query: {},
headers: {},
cookies: {},
body: null,
rawBody: async () => new Uint8Array(),
aborted: false,
...overrides,
}
}
function boot() {
defineConfig({ name: 'hello', drivers: {} })
Route.reset()
defineWebRoutes()
return createKernel()
}
test('the home route renders the greeting view', async () => {
const kernel = boot()
const result = await kernel.dispatch(Route.find('home'), request())
expect(result).toMatchObject({ type: 'view', view: 'greeting', props: { name: 'world' } })
})
test('the write route redirects to the named greeting', async () => {
const kernel = boot()
const result = await kernel.dispatch(
Route.find('greet.store'),
request({ method: 'POST', url: 'http://localhost/greet', body: { name: 'ada' } }),
)
expect(result).toMatchObject({ type: 'redirect', location: '/greet/ada' })
})
bun test
Let the linter hold the shape
bunx reeve bailiff
Bailiff is clean.
Bailiff reads the architecture, so a shortcut fails the build rather than the review. Generate a resource controller and leave its writes unvalidated:
bunx reeve make:controller Ledger --resource
bunx reeve bailiff
app/Http/Controllers/LedgerController.ts:13:3 @avelonjs/bailiff/require-request Write action store() must validate through a Request.
app/Http/Controllers/LedgerController.ts:25:3 @avelonjs/bailiff/require-request Write action update() must validate through a Request.
app/Http/Controllers/LedgerController.ts:29:3 @avelonjs/bailiff/require-request Write action destroy() must validate through a Request.
09-bailiff.md lists every rule and its reason.
Generate instead of typing
reeve make:controller PostController --resource
reeve make:request StorePostRequest
reeve make:view posts/Index
reeve make:model Post -mfcpw
reeve make:model Post -mfcpw writes the model, a migration, a factory, a controller, a policy, and a ward in one command. 08-cli.md is the full command list, and bare reeve opens the TUI.
Add a database
The app above declares drivers: {}. Point it at Postgres by wiring a driver in avelon.config.ts, which is one of the two places a vendor package may be imported:
import { defineConfig } from '@avelonjs/core'
import { createPostgresDatabase } from '@avelonjs/postgres'
import { migrations } from './database/migrations'
export default defineConfig({
name: 'hello',
drivers: {
database: createPostgresDatabase({
url: process.env.POSTGRES_URL ?? 'postgresql://postgres:avelon@127.0.0.1:5432/avelon',
migrations,
}),
},
} as const)
05-database.md covers models and migrations; 04-drivers.md covers what a driver must implement and how capability narrowing removes methods a driver cannot serve.
Where to go next
| Doc | Read it for |
|---|---|
01-thesis.md | The problem this framework is answering |
03-architecture.md | Packages, the adapter contract, the request lifecycle |
05-database.md | Models, the query IR, migrations |
06-authorization.md | Policies for abilities, Wards for rows |
07-events.md | Events, listeners, queued delivery |
08-cli.md | Every reeve command |
09-bailiff.md | The architecture rules and why each exists |
11-runtime.md | Bun, Vercel, and what runs where |