Lith = TypeScript + JSX + Prisma-style queries + the declarations table page api crud layout (at line start) and state/effect/auth. All .lith files share one global namespace: never import your own files or Lith names; import npm packages normally. JS built-ins (Date, Set, fetch, setTimeout, regex) work as in TS. Server code needs no await (harmless).
Tables
table Post { title: string(1..200) // refinement: length 1..200, checked on every write body: string // any text, "" allowed slug: string(1..80) unique // modifiers (unique, index, renamedFrom("old")) go anywhere after the base type views: int(0..) = 0 // int(a..b) / number(a..b): value range done: boolean = false status: "Draft" | "Live" = "Draft" due: day | null // day = "YYYY-MM-DD" (from <input type="date">); compare with today() author: User | null // reference: field authorId; include: { author: true } loads it tags: Tag[] // many-to-many (join table is automatic) created: Date = now() }
id: intis automatic; never declare it.T | null= optional. A new column with a default or| nullneeds no migration. To rename a column and keep its data:unitPriceCents: int(0..) renamedFrom("priceCents").table Note own { ... }addsowner: User, fills it on create and limits every query, update and delete to the logged-in user's rows (others act as missing; login required). Use it only for private data. Shared rows with an owner getowner: Userplus checks in code.system(() => ...)runs code withoutownscoping; it is allowed only inauth(...)pages/functions,testandseed { }blocks.Userexists (email: email unique,name: string,passwordHash;email= text "@" text "." text). Add columns withtable User { role: "Admin" | "Member" = "Member" }.passwordHashnever reaches the browser or JSON. Table names may be any word (Image,Event).
Queries (Prisma Client)
db.post.findMany findFirst findUnique findUniqueOrThrow count create update delete updateMany deleteMany with where data orderBy skip take include; operators equals not in contains startsWith endsWith lt lte gt gte AND OR NOT (contains and string sorting ignore case). findUniqueOrThrow → the row or a 404; update/delete of a missing row → 404. Nulls: orderBy: [{ done: "asc" }, { due: { sort: "asc", nulls: "last" } }]. Many-to-many: data: { tags: [tagA, tagB] } sets the list, where: { tags: { some: { name } } } filters, include: { tags: true }. A row loaded with include has those fields in its type: a posts: Post[] prop may read p.tags if the page loaded them.
Pages and layout
auth page "/posts/:id/:slug" (id: int, slug: string, q: string = "") { // path params typed and URL-decoded; const post = db.post.findUniqueOrThrow({ where: { id } }) // other params come from the query string if (slug !== post.slug) redirect(`/posts/${id}/${post.slug}`, 301) // default status 303 return <main><title>{post.title}</title><h1>{post.title}</h1><p>{post.created}</p></main> } layout { // optional; wraps every page; `children`, `me` and `path` are in scope return <html data-theme="system"> <nav><a href="/" class={path === "/" ? "font-bold" : ""}>Home</a></nav> {children} </html> }
auth page requires login (else redirect to /login); auth(me.role === "Admin") page also answers 403. me is the logged-in User or null (User under auth); path is the current URL path. A bad typed path param gives 404. Links are plain <a href>; a search form is <form method="get">. A Date renders as 2026-10-16 14:05. <html data-theme="light" | "dark" | "system"> in layout forces or frees light/dark (built-in styles and Tailwind dark:). Stop anywhere with redirect(path, status?), notFound(), forbidden(), unauthorized(), fail(...).
Server functions and forms
A top-level function that uses db, me, login/logout/loginWithPassword/hashPassword or process.env runs on the server; the browser calls it automatically. auth function requires login (401). Parameters are typed and validated.
<form action={addComment}><input type="hidden" name="postId" value={post.id} /><textarea name="body" /><button>Send</button></form> auth function addComment(postId: int, body: string(1..1000)) { if (body.includes("http")) fail({ body: "No links" }) // field message; fail("msg") = form message db.comment.create({ data: { postId, body, authorId: me.id } }) } <button onClick={() => deleteComment(c.id)}>Delete</button> // in a page a handler is one server-function call; // anything else needs a component with `state`
- Inputs, textareas, selects and hidden inputs fill parameters by
name, converted to the parameter type (int,number, checkbox →boolean,type="date"→day, repeated input names →T[]). An empty field is""forstringandnullforT | null. - All parameter checks run first, all at once.
email: User.emailreuses a column's type;email: User.email uniquealso checks it is not taken. For your own message, type the param loosely (url: string) andfailin the body. - On any failure the form re-shows by itself: each field message after its input, other messages at the end, typed values kept (not passwords). Nothing to write. Without JS it is a native POST.
fail("Already voted", 409)sets the status (default 400). - After a server function succeeds, the page re-renders from the server: island props update,
stateis kept and the form resets.redirectnavigates.
Components and client state
function Search(placeholder: string, onPick: (id: int) => void) { // props are parameters: <Search placeholder="Find" onPick={f} /> state q = "" state hits: Person[] | null = null // typed state const empty = hits !== null && hits.length === 0 // recomputes effect(() => { // runs after render and whenever q changes if (q === "") { hits = null; return } let stale = false const timer = setTimeout(async () => { const res = await fetch(`/api/people?q=${encodeURIComponent(q)}`) if (!stale) hits = await res.json() }, 300) return () => { stale = true; clearTimeout(timer) } // cleanup before the next run }) return <div><input value={q} onInput={e => q = e.target.value} placeholder={placeholder} />{empty && <p>No matches</p>}</div> }
No React hooks. A component with state runs in the browser too. It cannot read db or me, so pass rows as props. It may fetch the app's own routes. Server functions called from it are async: await them in try/catch (e.status, e.message). A component without state renders on the server and may read db and me. children: Html receives nested JSX. In handlers, e.target is the element (e.target.value, e.target.checked).
APIs
crud "/api/products" Product // GET list, GET /:id (404 {"error":"not found"}), POST 201, PATCH /:id, DELETE /:id 204 crud "/api/notes" Note: list, get // only some operations auth api GET "/api/orders.csv" (from: day | null) { // params from path, query string or JSON body return new Response(csv, { headers: { "Content-Type": "text/csv" } }) // or return a value → JSON 200 }
Errors are JSON: 400 {"error":msg,"field":f}, 401, 403, 404. Response.json(v, { status: 201 }) sets a status.
Auth
loginWithPassword(email, pw) → User | null, and it starts the session on success. login(user), logout(), hashPassword(pw) (scrypt).
Styling
Plain HTML already looks good (centered main, form as a column, li as a row, no ul bullets, styled controls, tables and links). Add Tailwind via class (not className, no style) only for extras: flex-row font-bold text-red-600 text-gray-500 whitespace-pre-wrap grid grid-cols-3 gap-4. Class strings may be combined (ok ? "a" : "b", p-2 ${x} ), but each class name must appear whole somewhere, never "bg-" + c.
Check and test
lith check reports the first error as JSON with a patch. test("name", () => { ... }) may sit in any file: fresh database per test, vitest expect, asUser(user, () => ...).