Your first app
Build a small blog end to end: a collection, two endpoints, and a dashboard page.
This guide builds a small blog from an empty directory: a collection for posts, two API endpoints, and a dashboard page that lists them. Along the way you meet each part of an ohne app once: the database, the API, and the dashboard, each in its smallest working form.
Scaffold
Create a project named blog and start the dev server. Installation covers what this sets up.
npm create ohne blog
cd blog
npm run devLeave dev running. It serves the dashboard at http://localhost:9000 and the API at http://localhost:9001, and regenerates types every time you save.
The collection
A collection is one file under collections/, named after it:
import { defineCollection, field } from 'ohnejs';
export default defineCollection({
fields: {
title: field('text'),
body: field('text'),
},
});Save it. The API reloads, and the Posts table now exists in .data/ohne.db, a SQLite file ohne creates for you. There are no migrations to write - schema sync explains how.
Both fields are required. A field needs a value unless you pass nullable: true or give it a default. A text field rejects the empty string unless you pass allowEmpty: true. Field types lists every type and its options.
The first endpoint
Routes are files too. A file's path under api/ is its URL, and a .get suffix sets the method:
import { defineHandler, query } from 'ohnejs';
export default defineHandler(() => query('Posts').findMany());posts.get.ts becomes GET /posts, live as soon as you save. The handler's return value is the response, and objects and arrays are sent as JSON. Routes covers the naming rules.
query('Posts') is fully typed. Codegen turned your collection into types when you saved, so field names and results are checked as you type. Reading records covers the query builder.
Try it:
curl http://localhost:9001/posts[]Creating posts
The POST route reads a JSON body and writes:
import { defineHandler, query, readJSONBody, setResponseStatus } from 'ohnejs';
export default defineHandler(async () => {
const input = await readJSONBody<{ title: string; body: string }>();
const result = await query('Posts').create(input);
if (!result.ok) {
setResponseStatus(422);
return { errors: result.errors };
}
setResponseStatus(201);
return result.record;
});readJSONBody reads the body and setResponseStatus sets the status.
create never throws for bad input. It returns a result you check:
ok: true-recordis the new post.ok: false-errorsmaps each failing field to a message.
You don't validate the body yourself. A missing field, a wrong value, or an unknown key comes back in errors, never as a crash.
curl -X POST http://localhost:9001/posts \
-H 'content-type: application/json' \
-d '{"title": "Hello", "body": "First post."}'The answer is 201 with the new post: your fields, plus the generated UUID and _updatedAt. If you leave body out, the same call answers 422:
{ "errors": { "body": "validation.required" } }Each value is a key from the framework's message catalogs, so a client can show it in the user's language.
To skip the error handling, use createOrThrow. It returns the record and throws on bad input, and the framework answers the 422 for you. Writing records covers the details, and errors covers the response shape.
Run the first curl again and your post is in the list.
A dashboard page
Open http://localhost:9000. No user exists yet, so the dashboard asks you to create one. Enter your name, email, and password, and choose Create account. You land on the overview, signed in as admin.
The dashboard is a browser app ohne serves straight from your .ts files, with no build step. Pages follow the same file convention as routes, under dashboard/pages/. Add one for your posts:
import { api, defineDashboardPage, each, h } from 'ohnejs/dashboard';
import { ref } from 'ohnejs/utils';
interface Post {
UUID: string;
title: string;
body: string;
}
export default defineDashboardPage(() => {
const posts = ref<Post[]>([]);
void api('GET /posts')
.then((response) => response.json())
.then((list: Post[]) => (posts.value = list));
return h(
'div',
null,
h('h1', null, 'Posts'),
each(
() => posts.value,
(post) => post.UUID,
(post) =>
h('article', null, h('h2', null, () => post().title), h('p', null, () => post().body)),
),
);
});posts.ts is the dashboard's /posts. ohnejs/base wraps every page in its frame, so the file holds only content.
api is a typed fetch against the API. It autocompletes your routes, like 'GET /posts', and returns the raw Response.
ref holds reactive state, h builds real DOM, and each renders a keyed list. Function children like () => post().title are reactive: when posts.value changes, only the changed nodes update.
The page works by URL right away, but the sidebar does not know about it. Add a link in ohne.config.ts:
import { defineConfig } from 'ohnejs';
export default defineConfig({
layers: ['ohnejs/base'],
auth: { cookieName: 'blog-session' },
dashboard: {
menu: {
posts: { parent: 'general', to: '/posts', label: 'Posts', icon: 'article' },
},
},
});posts is the link's id. parent: 'general' puts it in the first section, below the overview. to is the page's path, label shows as written, and icon names a Tabler icon. The sidebar covers sections, nesting, and moving what a layer declares.
One last file. Browser code type-checks against DOM types, not Node's, so the root tsconfig.json excludes dashboard/. Give the dashboard its own dashboard/tsconfig.json:
{
"extends": "../.ohne/browser/tsconfig.json"
}Codegen keeps the file it extends up to date: the DOM lib, app/ imports, and the generated types. To check it with your Node code, set the typecheck script in package.json to tsc && tsc -p dashboard/tsconfig.json.
Pick Posts in the sidebar, or open http://localhost:9000/posts, and your posts are on screen. From here, every dashboard save reloads the browser, and nothing restarts.
The finished app
The whole app, every file in one place:
import { defineCollection, field } from 'ohnejs';
export default defineCollection({
fields: {
title: field('text'),
body: field('text'),
},
});To go deeper into the dashboard:
- Pages - the page convention and how pages are served.
- Rendering -
h,each, and the router. - Reactivity -
refandcomputed. - Data - the
apihelper.