---
title: Server Actions
sidebar:
  icon: zap
description: Write a function on the server and call it from the browser - types stay in sync, the function never ships to the client.
---

Server actions ride on [tacho](/tacho/overview). Install it next to `oxidejs`:

```package-install
npm i tacho
```

Files named `*.server.ts` / `*.server.js` are server-only. A client import is replaced with a tacho stub that POSTs `/_action`. The original module never enters the client graph. Server and [Vite](https://vite.dev/) SSR (`import.meta.env.SSR === true`) keep the real functions. Method names are `<file>.<fn>` (`test.ping`).

```ts twoslash src/test.server.ts
export async function ping() {
  return "pong" as const;
}
```

```ts twoslash src/client.ts
// @filename: test.server.ts
export async function ping() {
  return "pong" as const;
}
// ---cut---
// @filename: client.ts
import { ping } from "./test.server";

const result = await ping();
```

`async function*` exports stream over tacho SSE. `oxidejs/tsconfig` makes `await ticks()` typecheck. `vite dev` and `rsbuild dev` serve `/_action` via middleware. Changing a `*.server.ts` file reloads that handler on both.

```ts twoslash src/stream.ts
// @filename: test.server.ts
export async function* ticks(n: number) {
  for (let i = 0; i < n; i++) yield i;
}
```

## Abort

Pass `{ signal }` last on any action — stream or unary. Types come from the real `*.server.ts`, so declare the last argument there:

```ts twoslash src/abort.ts
// @filename: test.server.ts
import type { ActionOptions } from "oxidejs";

export async function ping(_opts?: ActionOptions) {
  return "pong" as const;
}
export async function* ticks(n: number, _opts?: ActionOptions) {
  for (let i = 0; i < n; i++) yield i;
}
// ---cut---
// @filename: abort.ts
import { ping, ticks } from "./test.server";

const ac = new AbortController();
const stream = await ticks(10, { signal: ac.signal });
await ping({ signal: ac.signal });
ac.abort();
```

`useRequest().signal` follows that abort. Breaking a `for await` also cancels.

## useRequest

Call `useRequest()` inside an action for the inbound `Request`. It throws outside `/_action`.

```ts twoslash src/who.server.ts
import { useRequest } from "oxidejs";

export async function who() {
  return useRequest().headers.get("x-user");
}
```

## useCtx

`useCtx()` is tacho `ctx`: `{ req }` plus anything middleware or `createContext` added.

```ts twoslash src/me.server.ts
import { useCtx } from "oxidejs";

export async function me() {
  return useCtx<{ req: Request; user?: string }>().user;
}
```

## useEnv and useFetchCtx

On [`preset: "celld"`](/oxide/configuration#preset), `useEnv()` and `useFetchCtx()` are the Worker `env` and `ctx` from `fetch(request, env, ctx)` — same values as `useCtx().env` / `useCtx().fetchCtx`. They are `undefined` on the Node fetch preset.

```ts twoslash src/secret.server.ts
import { useEnv, useFetchCtx } from "oxidejs";

type Env = { SECRET: string };

export async function secret() {
  useFetchCtx()?.waitUntil?.(Promise.resolve());
  return useEnv<Env>()?.SECRET;
}
```

## Transport

Stubs share one tacho client. Pass static headers with [`actionHeaders`](/oxide/configuration#actionheaders). Functions cannot ship to the browser.

```ts twoslash
import oxide from "oxidejs/vite";

oxide({
  actionHeaders: { authorization: "Bearer x" },
});
```

[`actions: "ws"`](/oxide/configuration#actions) upgrades `/_action` to a WebSocket. Install `crossws`. It does not work with [`preset: "celld"`](/oxide/configuration#preset).

```ts twoslash
import oxide from "oxidejs/vite";

oxide({
  actions: "ws",
});
```
