A full-stack framework for TypeScript on Node.js
One type, from the database column to the form error.
Routes, controllers and views are typed end to end. The server renders each page before the browser takes over, and a new app starts with a database, sessions, validation, mail, a queue and a scheduler already wired together.
pnpm create marmeon my-app
cd my-app
pnpm dev
0.1.0 · release in preparation · MIT licence · Node 26.10+
import { can } from '@marmeon/auth';
import { Controller, defineRequest, type ContextOf } from '@marmeon/http';
import { NotePolicy } from '../policies/NotePolicy.ts';
export const ShowNoteRequest = defineRequest({ authorize: can(NotePolicy, 'view', 'note') });
export class ShowNoteController extends Controller {
static request = ShowNoteRequest;
// `note` is bound by the route: a missing one is a 404.
handle(ctx: ContextOf<typeof ShowNoteRequest>) {
return this.view('notes/Show', { note: ctx.params.note });
}
}import type { PageProps } from '@marmeon/http';
import { Head, Link } from '@marmeon/react';
import type { ShowNoteController } from '../controllers/ShowNoteController.ts';
export default function Show({ note }: PageProps<ShowNoteController>) {
return (
<main>
<Head title={note.title} />
<h1>{note.title}</h1>
<Link route="notes.index">All notes</Link>
</main>
);
}The page takes its prop types from the controller. Change one, and the other no longer compiles.
Seven principles
- Typed end to endFrom the database column through the controller, validation and route names to the form error in the React component.
- No global stateNo facades, no singletons holding request data: one application, a scope per request or job.
- TypeScript runs natively on NodeNo transpile step, no decorators.
- Modules firstA module brings its routes, providers, migrations, views and tests; installed packages register themselves.
- One endpoint, one classControllers, jobs, listeners, commands, mails and policies are classes with
handle(). - Batteries included, behind contractsMail, the queue, notifications, storage, events, live updates and the schedule each have a fake for tests.
- Secure by defaultIn production, and wherever nobody set the environment, protections stay on.
One type, end to end
A typo anywhere is a compile error.
The notes table is declared once, in a migration. From there the same type travels through the repository, the controller, the route name and the page, down to the error under a form field. Misspell a name at any step and the code no longer compiles.
Migration
modules/notes/migrations/The source of truth. Table types are generated from the migrations, never from a live database. Migrations
modules/notes/migrations/2026_10_01_120000_create_notes_table.ts import { defineMigration } from '@marmeon/database'; export default defineMigration({ up: (schema) => schema.create('notes', (table) => { table.id(); table.foreignId('user_id').constrained().cascadeOnDelete(); table.string('title', 200); table.text('body'); table.timestamps(); }), down: (schema) => schema.drop('notes'), });Table type
Row<'notes'>Every column of the table, typed. Repositories, controllers and pages all use this one type. Table types
modules/notes/types.ts import type { NewRow, Row, RowUpdate } from '@marmeon/database'; export type Note = Row<'notes'>; // a row as read export type NewNote = NewRow<'notes'>; // what an insert takes export type NoteChanges = RowUpdate<'notes'>; // what an update takesRepository
NoteRepository.tsKysely's query builder: every name is checked against the table's type. Rows in, rows out. Queries
modules/notes/NoteRepository.ts import { Repository } from '@marmeon/database'; export class NoteRepository extends Repository<'notes'> { static table = 'notes' as const; latestOf(userId: number) { return this.query().selectAll().where('user_id', '=', userId).orderBy('updated_at', 'desc').limit(10).execute(); } }This does not compile:
.where('user_ud', '=', userId)Controller
ShowNoteController.tsThe route binds the note before
handle()runs. A missing one is a 404. Route model bindingreturn this.view('notes/Show', { note: ctx.params.note });This does not compile:
{ note: ctx.params.nte }Route name
routes.tsPages link to routes by name. A name that does not exist does not compile. Routing
modules/notes/routes.ts import { authenticate } from '@marmeon/auth'; import { defineRoutes } from '@marmeon/http'; import { ListNotesController } from './controllers/ListNotesController.ts'; import { ShowNoteController } from './controllers/ShowNoteController.ts'; import { NoteRepository } from './NoteRepository.ts'; export default defineRoutes((Route) => { const notes = Route.prefix('/notes').middleware(authenticate()).name('notes.'); notes.get('/', ListNotesController).name('index'); notes.bind('note', NoteRepository).get('/:note', ShowNoteController).name('show'); });<Link route="notes.index">All notes</Link>This does not compile:
<Link route="notes.indx">export default function Show({ note }: PageProps<ShowNoteController>)This does not compile:
<h1>{note.titel}</h1>Form error
views/Edit.tsxThe form knows its method, URL and field types from the route name, so its errors are typed too. Forms
const form = useForm('notes.update', { note: note.id }, { title: note.title, body: note.body }); form.errors.titleThis does not compile:
form.errors.titel
Modules first
A feature lives in one folder.
A module in modules/<name>/ brings its own routes, controllers, React views, migrations, jobs, listeners, config and tests.
Installed framework packages join the app on their own. Add one, and it is registered. Discovery
pnpm add @marmeon/redis
- routes.tsroutes and their names
- controllers/one class per endpoint
- views/React pages
- migrations/the module's tables
- jobs/queued work
- notes.test.tstests, next to the code
- index.tsthe definition, and what other modules may import
Batteries included
Wired together, behind contracts.
Mail, the queue, notifications and storage have several drivers and a fake for tests. Every package is released together, always at the same version. All packages
In every new app
@marmeon/coreApp, modules, container, config, events, clock.marmeonThe CLI: dev, build, start, generators, tinker.@marmeon/routerRoutes, invokable controllers, request schemas, route model binding, middleware.@marmeon/httpPages and props, redirects, signed URLs, cookies.@marmeon/serverHTTP server, SSR, health checks, graceful stop.@marmeon/sessionSessions, flash data, CSRF.@marmeon/validationValidation rules, with messages in the request's language.@marmeon/i18nTranslations, the request's language and formatting.@marmeon/databaseMigrations, table types, repositories, factories, transactions, pagination.@marmeon/cacheCache, atomic locks, rate limits.@marmeon/authSign-in, hashing, password reset, email verification, API tokens, policies.@marmeon/mailMails as classes, queued sending, a fake for tests.@marmeon/queueJobs, workers, retries, a fake for tests.@marmeon/schedulerScheduled tasks, with a fake for tests.@marmeon/logThe logger: readable in development, JSON in production.@marmeon/reactPages, layouts, <Link>, forms, islands, dialogs.
Dev dependencies
@marmeon/viteThe plugin that also keeps server code out of the browser.@marmeon/testingcreateTestApp(), assertions, fakes.@marmeon/devtoolsRecords requests, queries, jobs and mails. Development only.
Add when you need it
@marmeon/redisRedis connections for the cache, sessions, the queue and live updates.@marmeon/storageLocal and S3 disks, signed URLs, uploads.@marmeon/livePages reload their data when the server hints a change.@marmeon/notificationsMail, database and live notifications.@marmeon/tableServer-driven data tables with sorting, filters, actions, CSV export.@marmeon/otelOpenTelemetry traces.
Secure by default
Secure when nobody changes a default.
- Fails closedIn production, and on a server where nobody set NODE_ENV, protections stay on.Security Overview
- CSRF protectionEvery request of the web group that may change something is checked, and a request from another site is refused.CSRF Protection
- Strict Content-Security-PolicyEvery new app turns on a policy with a fresh nonce for each document. The framework renders no style attributes, so styles need no 'unsafe-inline'.Content Security Policy
- Signed-in pages are never cachedAnswers for a signed-in user are sent no-store and private.Authentication
- Page data stays out of browser storageA history entry never holds the page props, and nothing a user saw is written to the browser storage.Navigation
- Devtools only in developmentAnd even there, behind a host guard that refuses every unknown host.Security Overview
Testing built in
A test goes through the whole kernel.
Sign in, visit the page, check what it renders and with which props. The session is included, and the database is migrated and empty for each test. Testing pages
A fake for each
- Queue
- Notifications
- Storage
- Events
- Live updates
- Schedule
it('shows a note to its author', async () => {
const app = await createTestApp(application, { database: 'refresh' });
const ada = await app.factory(UserFactory).create();
const note = await app.factory(NoteFactory).create({ user_id: ada.id, title: 'Groceries' });
await app
.actingAs(ada)
.navigating()
.get(`/notes/${note.id}`)
.assertOk()
.assertPage<ShowNoteController>('notes/Show', { note: { title: 'Groceries' } });
});Devtools
See what a request did.
In development, devtools records requests, queries, jobs and mails and shows them on a page of its own. Secrets are masked. In production it is not there. Devtools
Get started
Three commands to a running app.
The starter kit brings sign-in and registration, a home page, devtools, oxlint and Vitest. SQLite runs in a file, so no database server is needed. --minimal creates a blank app; --docker adds container files. The app answers on http://localhost:3000.
pnpm create marmeon my-app
cd my-app
pnpm dev