LearnFull-stack apps
Server functions
A page needs to read data and change it. In most stacks that means designing an API: routes, methods, request and response shapes, a client to call them, and code to keep the two sides in step. With @weft-js/start you write a function on the server and import it in a component. The call crosses the network, but your code doesn't have to know.
The last page, Loading data, read queries from load and from components. This one is about writing them, and their other half, mutations.
A function on the server
Server functions live in server modules: any file under src/server/, or any file named *.server.ts. A query reads; a mutation writes. Each is an export, made with query() or mutation(), and the code that runs on the server is its run:
// src/server/count.tsimport { query } from '@weft-js/start'import { eq } from 'drizzle-orm'import { todos } from '#/db/schema'export const countTodos = query({ tags: ['todos'], auth: 'user', run: async ({ ctx }) => (await ctx.db.select({ id: todos.id }).from(todos).where(eq(todos.userId, ctx.user.id))).length,})A component imports it and calls it:
import { countTodos } from '#/server/count'export function Counter() { const count = countTodos() return <p>{count.value ?? '…'} todos</p>}That's the whole API. There is no route to name, no fetch to write, and the types go straight through: count.value is a number | undefined because run returns a number.
What makes this safe is the build. The weft:start plugin gives every export in a server module an id, and in the browser's copy of the file it replaces each one with a small stub that calls /_weft/fn/<id>. run, the database import and everything else in the file never reach the browser. That is also why a query or mutation written anywhere else is an error, Q003: its run and imports would ship to the client, so the build refuses.
Queries
A query is a read. You've seen how to call one (Loading data): q(input) in a component gives a resource, and await q.fetch(input) gives the value once, in load, handlers and other server functions. What a query declares:
| Option | What it does |
|---|---|
run | The body: ({ input, ctx }) => value. Sync or async. |
input | A schema for the argument. With none, the query takes no input. |
tags | What invalidates it, usually the tables it reads. A function of the input for per-record tags. |
auth | 'user' refuses a call with no signed-in user. |
cache | { life } keeps the value across requests; scope: 'user' keeps one per user. |
output | A schema for the result, checked in development (C002). |
Queries are called with GET, may run more than once, and should change nothing. The cache relies on it: a query's answer may be reused, or fetched again whenever a tag is invalidated.
Mutations
A mutation is a write. It's declared the same way, with invalidates in place of tags:
// src/server/rename.tsimport { FnError, mutation } from '@weft-js/start'import { and, eq } from 'drizzle-orm'import { z } from 'zod'import { todos } from '#/db/schema'export const renameTodo = mutation({ input: z.object({ id: z.string(), title: z.string().trim().min(1, 'Give it a title.').max(200) }), auth: 'user', invalidates: ['todos'], run: async ({ input, ctx }) => { const [row] = await ctx.db .update(todos) .set({ title: input.title }) .where(and(eq(todos.id, input.id), eq(todos.userId, ctx.user.id))) .returning() if (!row) throw new FnError({ message: 'That todo is gone, or it isn\'t yours.' }, 404) return row },})Calling a mutation runs it, and returns a promise of what run returned. A handler is where that usually happens:
import { Button } from '@weft-js/warp'import { renameTodo } from '#/server/rename'export function MakeUrgent(props: { id: string; title: string }) { const rename = async () => { await renameTodo({ id: props.id, title: `Urgent: ${props.title}` }) } return ( <Button size="sm" loading={renameTodo.pending.value} onClick={() => void rename()}> Mark urgent </Button> )}A mutation also carries two boxes: renameTodo.pending.value is true while a call is in flight, and renameTodo.error.value holds the last call's failure until the next one succeeds. They're shared by every caller of that mutation. A form that needs its own pending state uses