Server Actions
Server actions are progressive enhancement for forms. They work like HTML forms have since 1993, except the action is a function instead of a URL.
Start with a Form
HTML forms work by sending data to the server via POST. The server processes it and redirects the user. This pattern is decades old and it works without JavaScript. Server actions preserve this: the action prop accepts a function instead of a URL, but the underlying mechanism is the same.
// app/routes/login.tsx
import { redirect } from '@cmj/juice/runtime';
async function login(formData: FormData) {
'use server';
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const user = await db.users.findByEmail(email);
if (!user || !await verifyPassword(password, user.passwordHash)) {
return { error: 'Invalid email or password' };
}
const session = await createSession(user.id);
// 303 tells the browser to follow with GET, preventing duplicate submissions
redirect('/dashboard', 303);
}
export default function Login() {
return (
<form action={login}>
<label>
Email
<input name="email" type="email" required />
</label>
<label>
Password
<input name="password" type="password" required />
</label>
<button type="submit">Log in</button>
</form>
);
}
export const response = {
head: { title: 'Log In' },
};This form works without JavaScript. When JS is disabled, Juice extracts the action ID into a hidden $ACTION_ID field. The form submits as a standard POST. The server resolves the action, executes it, and returns the redirect or re-rendered page.
The Two-Argument Signature
The first argument is always the body: FormData for form submissions, or a parsed JSON/text value for programmatic calls. The second argument is ActionContext, which gives you access to the raw request, cookies, and typed route params.
import type { ActionContext } from '@cmj/juice/runtime';
async function updateProduct(formData: FormData, ctx: ActionContext<'/product/:id'>) {
'use server';
const productId = ctx.params.id; // typed: string
const sessionId = ctx.cookies.get('sid'); // ReadonlyMap<string, string>
const origin = ctx.request.headers.get('origin');
if (!sessionId) {
return { error: 'Not authenticated' };
}
await db.products.update(productId, {
name: formData.get('name') as string,
price: Number(formData.get('price')),
});
redirect(`/product/${productId}`, 303);
}When do you need the second argument? When you need to read cookies for authentication, access route params for scoped mutations, or inspect request headers for CORS or content negotiation. If your action only processes form data, skip it.
Enhanced Forms with useActionState
The basic form above works without JavaScript but does not show pending state or inline errors. React 19's useActionState adds these capabilities. This requires a 'use client' wrapper because hooks run in the browser.
// app/components/login-form.tsx
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Logging in...' : 'Log in'}
</button>
);
}
export function LoginForm({
action
}: {
action: (formData: FormData) => Promise<{ error?: string } | void>
}) {
const [state, formAction] = useActionState(action, null);
return (
<form action={formAction}>
<label>
Email
<input name="email" type="email" required />
</label>
<label>
Password
<input name="password" type="password" required />
</label>
{state?.error && <p className="error">{state.error}</p>}
<SubmitButton />
</form>
);
}// app/routes/login.tsx -- Server component passes the action
import { LoginForm } from '../components/login-form';
async function login(formData: FormData) {
'use server';
// ... same validation logic as before
}
export default function LoginPage() {
return <LoginForm action={login} />;
}Without JavaScript: the form submits, the server returns the re-rendered page with the error message inline. With JavaScript: useActionState intercepts the submission, shows the pending state, and updates the error message without a full page reload. Same action, two experiences.
Optimistic Updates with useOptimistic
When the server round-trip takes time, you can show the result before the server confirms it. React 19's useOptimistic manages this. Juice's default callServer makes it work without any configuration -- it detects RSC responses and decodes them automatically via createFromFetch.
// app/components/todo-list.tsx
'use client';
import { useOptimistic } from 'react';
type Todo = { id: string; text: string };
export function TodoList({
todos,
addTodo,
}: {
todos: Todo[];
addTodo: (formData: FormData) => Promise<void>;
}) {
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(current: Todo[], newTodo: Todo) => [...current, newTodo]
);
async function handleSubmit(formData: FormData) {
const text = formData.get('text') as string;
// Show the item immediately (optimistic)
addOptimistic({ id: crypto.randomUUID(), text });
// Then send to server
await addTodo(formData);
// React reconciles: server state replaces optimistic state
}
return (
<div>
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
<form action={handleSubmit}>
<input name="text" placeholder="New todo" required />
<button type="submit">Add</button>
</form>
</div>
);
}// app/routes/todos.tsx
import { TodoList } from '../components/todo-list';
async function addTodo(formData: FormData) {
'use server';
const text = formData.get('text') as string;
await db.todos.create({ text });
}
export default async function Todos() {
const todos = await db.todos.list();
return <TodoList todos={todos} addTodo={addTodo} />;
}What happens: the user clicks "Add", the todo appears in the list immediately (optimistic), the server action runs, Juice's callServer receives the RSC response, React reconciles -- the optimistic entry is replaced by the real server state. If the action fails, React rolls back to the pre-optimistic state.
How callServer Works
When a server action runs in the browser, Juice's default callServerhandles the RPC. Understanding its behavior helps you debug action issues and explains why useOptimistic works out of the box.
// What callServer does under the hood:
// 1. POST to the current URL
// 2. Set header: x-juice-action-id = <action ID>
// 3. Send the serialized arguments as the body
// 4. Check the response Content-Type:
// - text/x-component → decode via createFromFetch (RSC reconciliation)
// - application/json → decode via response.json()
// 5. Return the decoded value to the calling componentThe Content-Type branching is the key detail. When the server returns an RSC payload (the default for actions that re-render), callServer feeds it to createFromFetch, which lets React reconcile the new tree with the existing DOM. This is why useOptimistic and useActionState work seamlessly -- the RSC response updates the component tree without a full page reload.
You can provide a custom callServer via initNavigationoptions if you need different behavior (custom headers, retry logic, error reporting).
Redirect After POST
Always use redirect(url, 303) after a successful mutation. Status 303 tells the browser to follow the redirect with a GET request. This is the POST-Redirect-GET pattern. Without it, refreshing the page re-submits the form.
Why 303 and not 302? 302 is ambiguous. Some browsers follow it with POST (preserving the original method), others with GET. 303 is explicit: always GET. The default in redirect() is 302 for backward compatibility, but pass 303 explicitly after form submissions.
File Uploads
File uploads work through standard FormData. The action receives the file as a File object. Juice does not abstract storage -- it gives you the File, you pick where it goes.
async function uploadAvatar(formData: FormData) {
'use server';
const file = formData.get('avatar') as File;
if (!file || file.size === 0) {
return { error: 'No file selected' };
}
if (file.size > 5 * 1024 * 1024) {
return { error: 'File too large (max 5MB)' };
}
const bytes = await file.arrayBuffer();
// Platform-specific storage:
// Cloudflare R2: await env.BUCKET.put(file.name, bytes);
// Bun: await Bun.write(`./uploads/${file.name}`, bytes);
// Node.js: await fs.writeFile(`./uploads/${file.name}`, Buffer.from(bytes));
redirect('/profile', 303);
}
export default function Upload() {
return (
<form action={uploadAvatar} encType="multipart/form-data">
<input type="file" name="avatar" accept="image/*" />
<button type="submit">Upload</button>
</form>
);
}Return Values
Server actions can return any JSON-serializable value. The return value is sent to the client and becomes the state in useActionState. If you return a non-serializable value (a class instance, a stream, a function), you get a clear error in dev mode.
For full control over the response (custom status codes, headers, binary data), return a Response object directly:
async function exportCSV(formData: FormData) {
'use server';
const data = await db.reports.generate(formData.get('type') as string);
const csv = convertToCSV(data);
return new Response(csv, {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': 'attachment; filename="report.csv"',
},
});
}When NOT to Use Server Actions
Server actions are request-response. Every call is a round trip to the server. They are the wrong tool for these cases:
- Real-time updates. Chat messages, live cursors, collaborative editing need persistent connections. Use WebSockets (
Bun.serve()has built-in WebSocket support) or Server-Sent Events. - Batch operations without UI feedback. If you are syncing 1000 records and do not need per-item UI updates, use a plain
fetch()call to an API route. Server actions are designed for form-like interactions, not bulk data pipelines. - Client-only mutations. Updating a UI counter, toggling a dropdown, managing a shopping cart before checkout -- these do not need the server. Use React state.
Coming from Remix: Remix actions and Juice server actions serve the same purpose, but Juice actions are React 19 server functions (co-located with the component, called directly), while Remix actions are exported from the route module and receive the entire request. Juice's approach is more granular -- you can have multiple actions per page without routing logic.