Walkthrough — dein erstes Feature
Wir bauen eine kleine Notes-App: Notizen mit Titel, Body, Tag, Pinned-Flag. Liste + Edit-Form, deutsche und englische Labels, Hot-Reload im Browser.
Am Ende:
- ein eigener Workspace unter
samples/apps/notes/ - ein Feature
notesmit Entity, 5 CRUD-Handlern, 2 Screens, Nav-Eintrag, i18n - läuft auf
http://localhost:4180mit Auto-Mint-JWT
Voraussetzung: Quickstart ist durch
(yarn kumiko dev läuft, Postgres + Redis sind oben).
Schritt 1 — Workspace anlegen
mkdir -p samples/apps/notes/{src/app,src/features/notes,public}cd samples/apps/notespackage.json:
{ "name": "@kumiko/sample-notes", "private": true, "type": "module", "scripts": { "dev": "PORT=4180 bun --env-file=../../../.env run kumiko-dev src/app/server.ts", "build": "bun kumiko-build" }, "dependencies": { "@kumiko/dev-server": "workspace:*", "@kumiko/framework": "workspace:*", "@kumiko/headless": "workspace:*", "@kumiko/renderer-web": "workspace:*", "react": "^19.2.0", "react-dom": "^19.2.0" }}tsconfig.json:
{ "extends": "../../../tsconfig.json", "include": ["src"] }yarn install einmal aus dem Repo-Root — Yarn picks the workspace up.
Schritt 2 — Schema
src/features/notes/schema/note.ts:
import { createBooleanField, createEntity, createSelectField, createTextField,} from "@kumiko/framework/engine";import type { EntityEditScreenDefinition, EntityListScreenDefinition,} from "@kumiko/framework/ui-types";
export const NOTE_TAGS = ["personal", "work", "idea"] as const;export type NoteTag = (typeof NOTE_TAGS)[number];
export const noteEntity = createEntity({ fields: { title: createTextField({ required: true, sortable: true, searchable: true }), body: createTextField({ multiline: { rows: 6 }, searchable: true }), tag: createSelectField({ options: NOTE_TAGS, default: "personal", sortable: true, filterable: true, }), pinned: createBooleanField({ default: false, sortable: true }), },});
export const noteEditScreen: EntityEditScreenDefinition = { id: "note-edit", type: "entityEdit", entity: "note", layout: { sections: [{ title: "notes:section.note", columns: 2, fields: [ { field: "title", span: 2 }, "tag", "pinned", { field: "body", span: 2 }, ], }], },};
export const noteListScreen: EntityListScreenDefinition = { id: "note-list", type: "entityList", entity: "note", columns: ["title", "tag", "pinned"], pageSize: 25, defaultSort: { field: "title", dir: "asc" },};Die Field-Factories sind typed — createSelectField({ options: NOTE_TAGS })
infert default als NoteTag. Schema-Konstanten (NOTE_TAGS) auch
exportieren, damit Seeds + Templates dieselben Werte teilen ohne Drift.
Schritt 3 — Feature
src/features/notes/feature.ts:
import { defineEntityCreateHandler, defineEntityDeleteHandler, defineEntityDetailHandler, defineEntityListHandler, defineEntityUpdateHandler, defineFeature,} from "@kumiko/framework/engine";import { noteEditScreen, noteEntity, noteListScreen } from "./schema/note";
const open = { access: { openToAll: true } } as const;
export const notesFeature = defineFeature("notes", (r) => { r.entity("note", noteEntity);
r.writeHandler(defineEntityCreateHandler("note", noteEntity, open)); r.writeHandler(defineEntityUpdateHandler("note", noteEntity, open)); r.writeHandler(defineEntityDeleteHandler("note", noteEntity, open)); r.queryHandler(defineEntityListHandler("note", noteEntity, open)); r.queryHandler(defineEntityDetailHandler("note", noteEntity, open));
r.screen(noteEditScreen); r.screen(noteListScreen);
r.nav({ id: "notes", label: "notes:nav.list", order: 10, screen: "note-list" }); r.nav({ id: "note-new", label: "notes:nav.new", parent: "notes", screen: "note-edit", order: 10, });});defineEntityCreateHandler und Geschwister sind die Standard-CRUD-
Handler — sie schreiben Events auf den Entity-Stream und aktualisieren
die Projection-Tabelle in derselben Transaktion. Custom-Logik? Eine
einzelne Zeile durch ein eigenes r.writeHandler({ ... }) ersetzen.
Schritt 4 — i18n
src/features/notes/i18n.ts:
import type { TranslationsByLocale } from "@kumiko/renderer";
export const notesTranslations: TranslationsByLocale = { de: { "notes:nav.list": "Notizen", "notes:nav.new": "Neue Notiz", "screen:note-list.title": "Notizen", "screen:note-edit.title": "Notiz bearbeiten", "notes:section.note": "Notiz", "notes:entity:note:field:title": "Titel", "notes:entity:note:field:body": "Text", "notes:entity:note:field:tag": "Kategorie", "notes:entity:note:field:pinned": "Angepinnt", "notes:entity:note:field:tag:option:personal": "Persönlich", "notes:entity:note:field:tag:option:work": "Arbeit", "notes:entity:note:field:tag:option:idea": "Idee", }, en: { "notes:nav.list": "Notes", "notes:nav.new": "New note", "screen:note-list.title": "Notes", "screen:note-edit.title": "Edit note", "notes:section.note": "Note", "notes:entity:note:field:title": "Title", "notes:entity:note:field:body": "Body", "notes:entity:note:field:tag": "Tag", "notes:entity:note:field:pinned": "Pinned", "notes:entity:note:field:tag:option:personal": "Personal", "notes:entity:note:field:tag:option:work": "Work", "notes:entity:note:field:tag:option:idea": "Idea", },};Convention für die Keys: <feature>:nav.<id>, screen:<id>.title,
<feature>:entity:<entity>:field:<name>,
<feature>:entity:<entity>:field:<name>:option:<value>. Listen-Cells
und Form-Selects ziehen ihre Labels automatisch über diesen Pfad.
Schritt 5 — Server starten
src/app/server.ts:
import { runDevApp } from "@kumiko/dev-server";import { notesFeature } from "../features/notes/feature";
await runDevApp({ features: [notesFeature], port: Number.parseInt(process.env["PORT"] ?? "4180", 10), clientEntry: "./src/app/client.tsx", htmlPath: "./public/index.html", watchDirs: ["./src", "../../../packages/*/src"],});src/app/client.tsx:
import { createKumikoApp } from "@kumiko/renderer-web";import { notesTranslations } from "../features/notes/i18n";import { AppShell } from "./shell";
createKumikoApp({ shell: AppShell, clientFeatures: [{ name: "notes", translations: notesTranslations }],});src/app/shell.tsx:
import { DefaultAppShell } from "@kumiko/renderer-web";import type { ReactNode } from "react";
export function AppShell({ children }: { readonly children: ReactNode }) { return <DefaultAppShell brand="Notes">{children}</DefaultAppShell>;}public/index.html — minimaler Shell:
<!doctype html><html><head><meta charset="utf-8"><title>Notes</title></head><body><div id="root"></div><script type="module" src="/client.js"></script></body></html>Schritt 6 — Run
cd samples/apps/notesyarn devBrowser auf http://localhost:4180 — du landest auf der Note-Liste
(noch leer). Sidebar zeigt Notizen mit Sub-Eintrag Neue Notiz.
Click „Neue Notiz” → Form mit 4 Feldern. Speichern → zurück zur Liste,
deine Notiz steht drin. Hot-Reload: ändere notesTranslations.de und
der Browser refresht.
Was du gerade gebaut hast
- Schema als typesafe Field-Factories — Compile-Error wenn du später eine Tag-Option umbenennst, die im Seed verwendet wird.
- 5 CRUD-Handler ohne handgeschriebene Zod-Schemas —
defineEntity*generiert sie aus dem Schema. - Realtime out of the box — eine Note in einem Tab erstellen, zweiter Tab sieht sie sofort (SSE-Broadcast).
- Optimistic locking — zwei parallele Edits auf derselben Note
zeigen
version_conflictmit Reload-Banner. - i18n — Tag-Options rendern als „Persönlich”/„Personal” je nach Browser-Locale, sowohl in Listen-Cells als auch im Form-Select.
Nächste Schritte
- Handler-Logik — ein
r.hook("preSave", ...)für Validierung über ein einzelnes Feld hinaus. Siehe Recipe: lifecycle-hooks. - Auth — User-Login, Tenant-Switching. Siehe
apps/ui-walkthrough. - Volltextsuche —
searchable: truemarkiert Felder, Meilisearch übernimmt. Siehe Recipe: search. - Tiefer ins Framework — Framework-Konzepte erklärt Pipeline, Multi-Tenant, Event-Sourcing in Ruhe.