Skip to content

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 notes mit Entity, 5 CRUD-Handlern, 2 Screens, Nav-Eintrag, i18n
  • läuft auf http://localhost:4180 mit Auto-Mint-JWT

Voraussetzung: Quickstart ist durch (yarn kumiko dev läuft, Postgres + Redis sind oben).

Schritt 1 — Workspace anlegen

Terminal window
mkdir -p samples/apps/notes/{src/app,src/features/notes,public}
cd samples/apps/notes

package.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

Terminal window
cd samples/apps/notes
yarn dev

Browser 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_conflict mit 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.
  • Volltextsuchesearchable: true markiert Felder, Meilisearch übernimmt. Siehe Recipe: search.
  • Tiefer ins FrameworkFramework-Konzepte erklärt Pipeline, Multi-Tenant, Event-Sourcing in Ruhe.