How to Cut Signup Abandonment with a Remix Action and useFetcher
A Remix action validates your signup server-side and returns field-level errors, so the form works without client JavaScript. useFetcher submits it without a full navigation and exposes the returned errors inline, while preserving the entered values. That removes the lost-input and JS-dependency causes of abandonment.
A Remix signup form leaks users when it clears on error or depends entirely on client JavaScript. Validate in a route action, return field-level errors, and submit with useFetcher — the form works without JS, errors show inline without a reload, and the entered values are preserved.
Why an action plus useFetcher
The Remix-specific construct is the route action: a server function a form posts to. As a native HTML submission it works before hydration. useFetcher then submits it via fetch without navigating away — perfect for an inline signup that should stay put and show errors in place — and exposes the returned data and a submission state. That's progressive enhancement built into the framework.
The action
Return both errors and the submitted values so the form can repopulate:
// app/routes/signup.tsx
import { json, redirect } from '@remix-run/node'
import type { ActionFunctionArgs } from '@remix-run/node'
export async function action({ request }: ActionFunctionArgs) {
const form = await request.formData()
const email = String(form.get('email') ?? '')
const password = String(form.get('password') ?? '')
const errors: Record<string, string> = {}
if (!email.includes('@')) errors.email = 'Enter a valid email.'
if (password.length < 8) errors.password = 'At least 8 characters.'
if (Object.keys(errors).length) {
return json({ errors, values: { email } }, { status: 400 })
}
// create the account here…
return redirect('/welcome')
}
Returning values: { email } is what lets the form refill the email after a failed submit.
The form with useFetcher
import { useFetcher } from '@remix-run/react'
export default function Signup() {
const fetcher = useFetcher<typeof action>()
const data = fetcher.data
const busy = fetcher.state !== 'idle'
return (
<fetcher.Form method="post" action="/signup">
<input name="email" type="email" defaultValue={data?.values?.email ?? ''} required />
{data?.errors?.email && <p className="error">{data.errors.email}</p>}
<input name="password" type="password" required />
{data?.errors?.password && <p className="error">{data.errors.password}</p>}
<button disabled={busy}>{busy ? 'Creating…' : 'Create account'}</button>
</fetcher.Form>
)
}
<fetcher.Form> submits without a navigation, defaultValue repopulates the email, and fetcher.state lets you disable the button while the request is in flight so impatient users don't double-submit. If JavaScript hasn't loaded, the form still posts to /signup natively.
Steps to apply it
- Add an
actionto the signup route that returnsjson({ errors, values })on failure. - Render the form with
<fetcher.Form method="post">so it submits in place. - Set each input's
defaultValuefromfetcher.data.valuesto preserve input. - Disable the submit button while
fetcher.state !== 'idle'. - Fire
signup_startedon first input andsignup_completedon the redirect.
Measure the result
SELECT
countDistinctIf(person_id, event = 'signup_started') AS started,
countDistinctIf(person_id, event = 'signup_completed') AS completed,
round(
countDistinctIf(person_id, event = 'signup_completed')
/ countDistinctIf(person_id, event = 'signup_started') * 100,
1) AS completion_rate_pct
FROM events
WHERE timestamp > now() - INTERVAL 30 DAY
Illustrative sample output:
| started | completed | completion_rate_pct |
|---|---|---|
| 1,300 | 910 | 70.0 |
A 70% completion rate is healthy; track it after the change to confirm the improvement. If you'd rather have the weakest point found and shipped as a Pull Request, that's what Velyr does.
Frequently asked questions
What is a Remix action?
An action is a server-side function exported from a route module that handles non-GET requests. A form posts to it as a native HTML submission, so it works without client JavaScript; Remix then progressively enhances the form to submit via fetch without a full page reload.
Why use useFetcher instead of a plain Remix Form?
useFetcher submits without changing the route or triggering navigation, which is ideal for an inline signup that should stay on the same page and show errors in place. It exposes the action's returned data and a state you can use to disable the button while submitting.
How do I keep signup values after an error in Remix?
Return the submitted values alongside the errors from your action with json({ errors, values }). Read fetcher.data on the next render and set each input's defaultValue from it, so a failed submit repopulates the form.
Velyr is an AI growth agent that ships one weekly conversion fix as a GitHub Pull Request — you approve it over Telegram, and it rolls itself back if the numbers drop.
Start the Growth Agent