Create a project, add a page and a JSON route, and run it in production mode. No configuration, no environment variables.
Amoxi is an open-source framework for Node.js and TypeScript. You write services, pages and APIs as ordinary classes with decorators, and amoxi build turns them into plain wiring code before the app starts. If you have used Next.js, the commands will feel familiar; if you have used Spring or Micronaut, the style will.
In this article you will create an app, find your way around it, add a page and an API route of your own, and run it the way it runs in production. You need Node.js 22 or newer. Every command works the same in PowerShell, Command Prompt, bash and zsh.
Create the project
npx create-amoxi-app@latest my-app --template web
cd my-app
npm run dev
Open http://localhost:8080. You get a home page with a counter, a small notes list with a form, and an About page. Click between Home and About: the page changes without a full reload. Edit any file under src/ and save, and the browser updates on its own.
The web template gives you pages, interactive components and a JSON API. Two other templates exist: api (a JSON API only) and agent (the web template plus an AI agent, covered in article 5).
Find your way around
-
src/pages/: pages, classes whose methods return JSX, one method per URL. -
src/islands/: interactive components that run in the browser. -
src/notes/: a service and a JSON API for the notes. -
src/styles/: stylesheets, bundled with content-hashed names. -
src/main.ts: starts the app; three lines you rarely touch. -
application.json: configuration, with per-environment overrides. -
test/: tests, run withnpm test.
Open src/notes/note-service.ts. It is a plain class marked as a service:
import { PostConstruct, Singleton, Value } from 'amoxi';
import type { NewNote, Note } from './note';
@Singleton()
export class NoteService {
private readonly notes: Note[] = [];
private nextId = 1;
constructor(@Value('${notes.max:100}') private readonly max: number) {}
@PostConstruct()
seed(): void {
this.add({ text: 'Welcome to Amoxi. Edit a file in src/ and save to see it update.' });
}
list(): Note[] {
return [...this.notes].reverse();
}
add(input: NewNote): Note {
if (this.notes.length >= this.max) this.notes.shift();
const note: Note = { id: this.nextId++, text: input.text.trim(), createdAt: new Date().toISOString() };
this.notes.push(note);
return note;
}
}
@Singleton() means there is one instance for the whole app. @Value('${notes.max:100}') reads notes.max from configuration, with 100 as the default. You never write new NoteService(): anything that needs it asks for it in its constructor, and Amoxi passes it in. Article 2 explains how that works.
Add a JSON route
Add a method that finds one note to NoteService:
get(id: number): Note | undefined {
return this.notes.find((n) => n.id === id);
}
Then open src/notes/note-controller.ts and add a route that returns it:
import { Body, Controller, Get, HttpResponse, PathParam, Post } from 'amoxi';
import type { NewNote, Note } from './note';
import { NoteService } from './note-service';
@Controller('/api/notes')
export class NoteController {
constructor(private readonly notes: NoteService) {}
@Get()
list(): Note[] {
return this.notes.list();
}
@Get('/:id')
get(@PathParam() id: number): Note | HttpResponse<{ error: string }> {
return this.notes.get(id) ?? HttpResponse.notFound(`No note with id ${id}`);
}
@Post()
create(@Body() body: NewNote): HttpResponse<Note> {
return HttpResponse.created(this.notes.add(body));
}
}
Save, then open http://localhost:8080/api/notes/1 in your browser. You get the welcome note as JSON:
{"id":1,"text":"Welcome to Amoxi. Edit a file in src/ and save to see it update.","createdAt":"2026-10-19T16:19:36.923Z"}
Now try http://localhost:8080/api/notes/abc. The answer is a 400 with path parameter 'id' must be a number, because the parameter is typed number and the generated code checks it before your method runs. http://localhost:8080/api/notes/9 returns your 404: No note with id 9.
The POST route was already there. Its body is typed NewNote, and Amoxi generates a validator from that type at build time. Go back to the home page and submit the form with only spaces in it: the form shows "Note must not be blank." That message comes from the API, which answered with a 400 listing every problem:
{ "error": "Invalid request body", "details": [{ "path": "text", "message": "must not be blank" }] }
The rule itself is a JSDoc comment on the type, in src/notes/note.ts. Article 4 shows how far that goes.
Add a page
Pages are methods that return JSX, rendered to HTML on the server. Add one to src/pages/home-page.tsx, next to the existing pages:
import { Controller, ErrorPage, Island, Page, PathParam, withStatus, type RequestContext } from 'amoxi';
// inside the HomePage class
@Page('/notes/:id')
note(@PathParam() id: number) {
const note = this.notes.get(id);
if (!note) return withStatus(404, this.notFound());
return (
<Layout title={`Note ${note.id}`}>
<div class="message">
<h1>Note {note.id}</h1>
<p>{note.text}</p>
<p><a href="/">All notes</a></p>
</div>
</Layout>
);
}
Open http://localhost:8080/notes/1. The page is plain HTML: it works with JavaScript turned off, and it sends no JavaScript of its own. withStatus(404, ...) reuses the template's "Page not found" page with the right status code.
Notice class, not className: Amoxi's JSX writes HTML, so it uses HTML's attribute names.
Build and run it like production
Stop the dev server with Ctrl+C, then:
npm run build
npm start
npm run build checks the whole app and writes the wiring code into src/generated/. npm start runs amoxi start, which starts one worker per CPU core, restarts a worker if it crashes, and shuts down cleanly on Ctrl+C. Each worker is ready in well under a second.
Run the tests too. The template ships one that starts the app on a free port and calls it over HTTP:
npm test
What just happened
When you ran npm run build, Amoxi read your code with the TypeScript compiler. It found the services, routes and pages, checked that every constructor parameter has exactly one matching service, generated a validator for NewNote and a parser for each path parameter, and bundled the two interactive components for the browser. At runtime there is no scanning and no reflection: the app runs the code the build wrote.
That is why mistakes show up early. Rename NoteService in one place and forget another, or type a route parameter that is not in the path, and amoxi build stops with the file and line, before anything reaches a server.
Next
Article 2 looks at that wiring step: how services find each other, how configuration reaches them, and which mistakes the build catches for you.
Amoxi is open source under the Apache License 2.0. Code: github.com/karawash-projects/amoxi. Documentation: karawash-projects.github.io/amoxi.
Top comments (0)