Server Actions
Server actions let you handle form submissions and mutations on the server using the 'use server' directive. They work with and without JavaScript enabled.
Basic Form Example
// app/routes/contact.tsx
import { redirect } from '@cmj/juice/runtime';
async function submitForm(formData: FormData) {
'use server';
const name = formData.get('name') as string;
const email = formData.get('email') as string;
await db.contacts.create({ name, email });
redirect('/thank-you', 303);
}
export default function Contact() {
return (
<form action={submitForm}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit">Send</button>
</form>
);
}ActionContext
When you need access to the raw Request, cookies, or typed route params, accept an ActionContext as the second argument:
import { createActionContext } from '@cmj/juice/runtime';
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 session = ctx.cookies.get('sid'); // ReadonlyMap<string, string>
const origin = ctx.request.headers.get('origin');
await db.products.update(productId, {
name: formData.get('name') as string,
});
redirect(`/product/${productId}`, 303);
}useActionState and useFormStatus
Use React 19's useActionState hook for optimistic updates and useFormStatus for pending states. These require a client component wrapper.
// app/components/contact-form.tsx
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Sending...' : 'Send'}</button>;
}
export function ContactForm({ action }: { action: (formData: FormData) => Promise<void> }) {
const [state, formAction] = useActionState(action, null);
return (
<form action={formAction}>
<input name="name" required />
<SubmitButton />
{state?.error && <p className="error">{state.error}</p>}
</form>
);
}Progressive Enhancement
Server actions work without JavaScript. Juice extracts the action ID into a hidden $ACTION_ID form field. When JS is disabled, the form submits as a standard POST request. The server resolves the action from the hidden field, executes it, and returns the redirect or re-rendered page.
Redirect After POST
Always use redirect(url, 303) after a successful mutation. The 303 status code tells the browser to follow the redirect with a GET request, preventing duplicate submissions on refresh.
async function createPost(formData: FormData) {
'use server';
const post = await db.posts.create({
title: formData.get('title') as string,
});
redirect(`/blog/${post.slug}`, 303);
}File Uploads
File uploads work through standard FormData. The action receives the file as a File object from formData.get(). Make sure your form uses encType="multipart/form-data".
async function uploadAvatar(formData: FormData) {
'use server';
const file = formData.get('avatar') as File;
const bytes = await file.arrayBuffer();
await storage.put(`avatars/${file.name}`, 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>
);
}