GitHubnpm

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>
  );
}