Server functions

The one data channel from page to database — loaders, React Query, authedFn and adminFn, when to use an API route instead, and rate limiting.

Every page in ShipKit reads and writes data the same way. Once you have followed one example end to end, you can follow all of them, and a new page is a copy of an existing one.

route loader -> queryClient.ensureQueryData(query) -> server function -> Drizzle -> D1

A read, end to end

The credits page is a good example. Start with the server function, in src/features/credits/server/fns.ts:

import { authedFn } from '@/core/server/fn'

export const getMyCreditsFn = authedFn.handler(async ({ context }) => {
  const { total: _total, ...rest } = await myCredits(context.session.user.id)
  return rest
})

authedFn has already checked the session before the handler runs. If there is none, the call redirects to /login; if there is, context.session holds the signed-in user.

Next, a query in the feature's queries.ts gives that function a cache key:

export const myCreditsQuery = queryOptions({
  queryKey: ['credits'],
  queryFn: () => getMyCreditsFn(),
})

The route loader warms the cache before the page renders (src/routes/_app/credits.tsx):

export const Route = createFileRoute('/_app/credits')({
  loader: ({ context }) => context.queryClient.ensureQueryData(myCreditsQuery),
  component: CreditsPage,
})

Components then read the same query with useQuery(myCreditsQuery) or useSuspenseQuery(myCreditsQuery). The data is already there, so nothing loads twice. On a hard page load the loader runs on the server and the result is sent along with the page; on client navigation the same function is called over the network. Loaded data counts as fresh for 30 seconds (staleTime in src/integrations/tanstack-query/root-provider.tsx), and hovering a link preloads its route.

Writes and input validation

A write is a server function with a validator, called from useMutation, followed by invalidating whatever it changed:

export const adjustCreditsAdminFn = adminFn
  .validator(
    z.object({
      userId: z.string().min(1).max(100),
      amount: z.number().int().min(-1_000_000).max(1_000_000),
      reason: z.string().trim().min(1).max(200),
    }),
  )
  .handler(async ({ data, context }) => {
    await assertPermission(context.session, 'credits.write')
    // ...insert the ledger row, audit it, notify the user
  })
const adjust = useMutation({
  mutationFn: () => adjustCreditsAdminFn({ data: { userId, amount, reason } }),
  onSuccess: () => {
    void queryClient.invalidateQueries({
      queryKey: ['admin', 'users', userId, 'credits'],
    })
  },
})

The validator is a zod schema. data inside the handler is typed and already checked, so a handler never parses its own input.

authedFn and adminFn

Both live in src/core/server/fn.ts and are the only two starting points for a server function.

Starting pointChecksUse for
authedFnA signed-in session, else redirect to /loginEverything a user does with their own account
adminFnThe above, plus the admin.access permission, else redirect to /dashboardAnything in the admin console

adminFn only answers "may this person open the console at all". Each admin function must also name the specific permission it needs with assertPermission(context.session, '<permission>'), as above. A unit test, src/core/authz/registry.test.ts, fails for any admin function that forgets. Roles and permissions are covered in Permissions.

Do not check sessions by hand inside a handler, and do not wrap createServerFn in a factory of your own (a permissionFn('credits.write') helper, say). The build cannot see through the factory call and ends up shipping the whole module, database code included, to the browser.

Both are POST functions. A GET server function can be triggered from a plain link, and because the session cookie is sent on top-level navigations from other sites, a hostile page could link a signed-in user straight into a write. On top of that, src/server.ts refuses any call to /_serverFn/* whose Sec-Fetch-Site (or Origin) header says it came from another site.

Keeping server code out of the browser

A feature's server/fns.ts is imported by client code (through queries.ts), so the build turns each server function into a small client stub and strips what the handlers use. What it cannot strip is anything outside a handler. In practice:

  • Put database helpers in their own server module (the credits feature has server/credits.ts, server/me.ts, server/admin.ts) and call them from inside handlers.
  • Never add a plain helper that touches the database to src/core/server/fn.ts. It is bundled for the browser, which is why assertPermission lives in src/core/authz/assert.ts instead.
  • Constants the UI shares with the server go in the feature's config.ts, not schema.ts.

Architecture explains the bundle rules in more detail.

Return keys, not translated text

A server function is reached at /_serverFn/..., a URL with no locale in it. Inside one, Paraglide falls back to the cookie or the Accept-Language header, not the language of the page the user is looking at. Someone who followed a link to /zh would get English strings back.

So server functions return data and machine-readable reasons, and the component picks the message. The affiliate payout request is a good example:

// server: src/features/affiliate/server/fns.ts
if (amount < minimum) {
  return { ok: false as const, reason: 'below_minimum' as const, minimum }
}
// client: src/routes/_app/affiliate.tsx
toast.error(
  result.reason === 'below_minimum'
    ? m.affiliate_payout_below_minimum({ amount: money(result.minimum ?? minimum) })
    : m.affiliate_payout_open_request(),
)

The exception is text that leaves the app: emails and in-app notifications are localized by the recipient's saved user.locale, never by the request. See Internationalization and Email and notifications.

When an API route is right

API routes are files under src/routes/api/ with server.handlers instead of a component. They are for callers that are not your own pages:

RouteWhy it is not a server function
api/auth.$.tsbetter-auth's endpoints and OAuth callbacks
api/webhooks.stripe.ts, api/webhooks.creem.tsSigned calls from the payment provider
api/files.ts, api/files.$fileId.ts, api/avatar*.ts, api/feedback-image*.tsMultipart uploads and streamed downloads
api/v1/admin/*, api/v1/me/*The external API, authenticated by API key
api/ph/$.tsA proxy for product analytics

If your own page needs data, write a server function. The external API has its own wrappers (adminApi, userApi, userDoc, userAction in src/core/server/api.ts) that handle key checks and the JSON envelope; see API keys.

Rate limiting

isRateLimited in src/core/server/rate-limit.ts counts requests against Cloudflare's rate-limiting bindings, declared under ratelimits in wrangler.jsonc:

BindingLimitUsed for
PUBLIC_RATE_LIMIT5 per minuteWrites that cost real resources: uploads, sign-in code requests
ANALYTICS_RATE_LIMIT200 per minuteThe analytics proxy, which the browser calls several times per page
if (await isRateLimited('files-upload', { headers: request.headers, key: session.user.id })) {
  return tooManyRequests()
}

The first argument names the bucket. By default the count is per client IP; pass key to count per user or per email address instead. Inside a server function you can omit headers, because the request is implicit there. If the binding is missing the check fails open, so a configuration gap never takes an endpoint down.

Most reads do not need it: they are cheap and already tied to a session, and better-auth and the API-key plugin throttle their own endpoints. Reach for it where a request costs something, such as an R2 write or an outbound call, or where no session stands in front of it.