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