All docs
3 min read

Remix recipe

Remix actions are the natural fit. Form posts to the same route, action runs on the server, redirect or return data.

Basic action

// app/routes/contact.tsx
import type { ActionFunctionArgs } from '@remix-run/node';
import { Form, useActionData, useNavigation, redirect, json } from '@remix-run/react';
import { z } from 'zod';

const ContactSchema = z.object({
  email: z.string().email('Enter a valid email.'),
  message: z.string().min(1, 'Say something.'),
});

export async function action({ request }: ActionFunctionArgs) {
  const fd = await request.formData();
  const result = ContactSchema.safeParse(Object.fromEntries(fd));

  if (!result.success) {
    return json({ errors: result.error.flatten().fieldErrors }, { status: 400 });
  }

  const r = await fetch(process.env.FORMSPRING_URL!, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(result.data),
  });

  if (!r.ok) {
    return json({ errors: { _form: ['Submission failed.'] } }, { status: 502 });
  }

  return redirect('/contact/thanks');
}

export default function Contact() {
  const data = useActionData<typeof action>();
  const nav = useNavigation();
  const sending = nav.state === 'submitting';

  return (
    <Form method="post">
      <input name="email" type="email" required />
      {data?.errors?.email && <p>{data.errors.email}</p>}

      <textarea name="message" required />
      {data?.errors?.message && <p>{data.errors.message}</p>}

      <button disabled={sending}>{sending ? 'Sending…' : 'Send'}</button>
      {data?.errors?._form && <p className="err">{data.errors._form}</p>}
    </Form>
  );
}

<Form> from Remix progressively enhances — works without JS as a native form post, upgrades to a fetch when JS is available. Same code, both modes.

Fetcher pattern (no navigation)

Use useFetcher when you want to submit without leaving the page (sidebar contact widget, modal, multi-step form):

import { useFetcher } from '@remix-run/react';

export function ContactWidget() {
  const fetcher = useFetcher<typeof action>();
  const sending = fetcher.state !== 'idle';
  const ok = fetcher.data && !('errors' in fetcher.data);

  return (
    <fetcher.Form method="post" action="/contact">
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button disabled={sending}>Send</button>
      {ok && <p>Thanks.</p>}
    </fetcher.Form>
  );
}

The action lives at /contact, but the form submits without navigating away.

File uploads

Remix has built-in multipart parsing via unstable_parseMultipartFormData. For Formspring, simpler is to forward the original request body:

export async function action({ request }: ActionFunctionArgs) {
  // Stream the multipart body straight to Formspring
  const r = await fetch(process.env.FORMSPRING_URL!, {
    method: 'POST',
    headers: { 'Content-Type': request.headers.get('content-type')! },
    body: request.body,
    duplex: 'half',
  } as RequestInit);

  if (!r.ok) {
    return json({ error: 'Upload failed.' }, { status: 502 });
  }
  return redirect('/contact/thanks');
}

This avoids buffering the file in your server's memory.

Verifying webhooks in a Remix resource route

Resource routes (no default export) handle non-page traffic:

// app/routes/api.formspring-webhook.tsx
import type { ActionFunctionArgs } from '@remix-run/node';
import { createHmac, timingSafeEqual } from 'node:crypto';

export async function action({ request }: ActionFunctionArgs) {
  const sig = request.headers.get('x-formspring-signature') ?? '';
  const raw = await request.text();
  const expected = createHmac('sha256', process.env.WEBHOOK_SECRET!).update(raw).digest('hex');

  if (
    sig.length !== expected.length ||
    !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
  ) {
    return new Response('unauthorized', { status: 401 });
  }

  const body = JSON.parse(raw);
  // …handle event

  return new Response('ok');
}

What's next