Docs

Guide 00

Getting started

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

ToolVersionWhy
Bun1.3.14The pinned toolchain. 11-runtime.md has the runtime matrix.
Next16.2.12@avelonjs/next is the v1 adapter and targets the App Router.
React19.2.0Server 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:

CommandAppPort
bun run dev:gazetteIsle Gazette, the reference application3010
bun run dev:docsThese docs, as an Avelon app3011
bun run dev:starterSaaS starter: auth, dashboard, settings3012

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:

FileWhat lives there
routes/web.tsEvery route in the application
app/Http/Controllers/GreetingController.tsOne method per route
app/Http/Requests/GreetRequest.tsValidation for a write
resources/views/tokens.tsOpaque view names the controller returns
resources/views/pages/greeting.tsxThe React component behind a token
resources/views/registry.tsTokens mapped to components
resources/views/layouts/AppLayout.tsxThe root layout the generated router re-exports
src/boot.tsConfig, routes, kernel
src/boot-next.tsAdapter 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

DocRead it for
01-thesis.mdThe problem this framework is answering
03-architecture.mdPackages, the adapter contract, the request lifecycle
05-database.mdModels, the query IR, migrations
06-authorization.mdPolicies for abilities, Wards for rows
07-events.mdEvents, listeners, queued delivery
08-cli.mdEvery reeve command
09-bailiff.mdThe architecture rules and why each exists
11-runtime.mdBun, Vercel, and what runs where