Remix Forms vs Next.js Actions: Two Ways to Handle Mutations
Remix forms and Next.js Server Actions both handle mutations without client-side state — but they do it very differently. Here's which one actually fits your project.
The Core Difference You Need to Understand First
Honestly, most comparisons of Remix and Next.js forms miss the actual point. They get tangled up in API surface and syntax, when the real difference is a philosophical one: Remix bet on the browser's native <form> element, and Next.js bet on async functions called from anywhere.
Remix's approach is that HTML forms — the kind browsers have shipped for 30 years — should just work. No JavaScript required. You define an action export on your route, the form POSTs to it, and Remix handles the redirect, revalidation, and error state. It's the progressive enhancement model taken to its logical conclusion.
Next.js Server Actions take a different angle. You write an async function, mark it 'use server', and call it from a button click, a form's action prop, or literally anywhere in your component tree. It's more flexible but it also means more surface area to get wrong. Both models work. Which one fits you depends on what you're actually building.
How Remix Route Actions Work
In Remix (v2.x), every route file can export an action function. When a <form> inside that route submits, Remix intercepts the request, runs the action on the server, and then re-runs the route's loader to refresh the page data. You don't manage loading state manually. You don't call router.refresh(). It just revalidates.
Here's what a basic Remix action looks like in practice:
// app/routes/contact.tsx
import { Form, useActionData } from '@remix-run/react';
import type { ActionFunctionArgs } from '@remix-run/node';
import { json, redirect } from '@remix-run/node';
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const email = formData.get('email') as string;
if (!email || !email.includes('@')) {
return json({ error: 'Valid email required' }, { status: 400 });
}
await saveContact(email); // your DB call
return redirect('/thank-you');
}
export default function ContactPage() {
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<input name="email" type="email" />
{actionData?.error && <p>{actionData.error}</p>}
<button type="submit">Subscribe</button>
</Form>
);
}Notice there's no useState, no fetch, no manual error handling wiring. useActionData gives you whatever the action returned. If JavaScript is disabled, the form still submits and the server still responds. That's the Remix promise. For content-heavy sites or apps where accessibility and resilience matter, that's genuinely great.
How Next.js Server Actions Work
Next.js Server Actions (stable since Next.js 14) work differently. You define an async function with the 'use server' directive, and you can pass it directly to a form's action prop or call it from an event handler. The function runs on the server, and you get the result back on the client.
// app/contact/page.tsx (Next.js 14+)
'use client';
import { useActionState } from 'react';
async function submitContact(prevState: any, formData: FormData) {
'use server';
const email = formData.get('email') as string;
if (!email || !email.includes('@')) {
return { error: 'Valid email required' };
}
await saveContact(email);
return { success: true };
}
export default function ContactPage() {
const [state, formAction, isPending] = useActionState(submitContact, null);
return (
<form action={formAction}>
<input name="email" type="email" />
{state?.error && <p>{state.error}</p>}
<button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : 'Subscribe'}
</button>
</form>
);
}The useActionState hook (shipped in React 19) gives you isPending for free, which is handy for disabling the submit button. You can also call the same server action from a regular onClick — no form required. That flexibility is exactly what makes it useful for things like inline editing, optimistic updates, or batch operations that don't map cleanly to a form submission.
Validation: Where Each Model Gets Messy
Validation is where things get interesting. In Remix, you typically validate inside the action function and return errors as JSON. The useActionData hook picks them up. It works, but if you want field-level errors, you're building that yourself or reaching for a library like conform (which is excellent, by the way).
With Next.js, many teams pair Server Actions with react-hook-form on the client side and only use the server action for the final submit. You get rich client-side UX, type-safe validation with Zod, and server-side re-validation as a safety net. If you're already doing something like that in your project, check out how react-hook-form integrates with typed forms — it plays well with Server Actions.
The honest comparison: Remix's model is simpler when your validation lives entirely on the server. Next.js Server Actions are simpler when you already have client-side validation logic and just need to call a server function at the end. Neither is wrong. They're optimized for different team styles.
Error Handling and Toast Notifications
What happens when a mutation fails? In Remix, you return an error from the action and render it via useActionData. Clean, no extra setup. But if you want a toast notification popping up — that's where you need a bit more plumbing, since Remix's model is server-driven and toasts are inherently client-side concerns.
In Next.js, calling a server action from a client component means you have access to the result right there in your component. You can fire a toast directly after await formAction(). Libraries like Sonner or your own React toast notification setup slot in naturally here. The client-component context gives you more control over that kind of feedback.
Does that mean Next.js wins for UX? Not necessarily. Remix has useFetcher for non-navigating mutations, which lets you handle responses client-side too. The patterns just look different. With useFetcher, you get .state, .data, and .submit on a per-fetcher basis, which is actually quite nice for managing multiple concurrent mutations on a single page.
Performance and Bundle Size Implications
Here's something that doesn't get discussed enough: both of these approaches can accidentally bloat your client bundle if you're not careful. With Next.js Server Actions, it's easy to slip a 'use server' function into a file that also imports heavy client-only dependencies. That can cause subtle bundling issues.
Remix keeps a tighter boundary by design — your action and loader functions are always in separate module evaluation paths from your component code. The Remix compiler (Vite-based in v2.x) is quite good at tree-shaking server code out of the client bundle. If bundle size is a concern for your project, React performance patterns covers some of this territory in more detail.
In practice, for most CRUD-heavy apps the bundle difference is negligible. Where you'll feel it is in edge deployments. Remix was designed with edge runtimes in mind from the start, and it shows. Next.js Server Actions work on the edge too, but you have to be more deliberate about what you're importing in those functions.
TypeScript Experience: Which One Has Better DX?
Type safety is where the day-to-day developer experience really diverges. Remix's useActionData returns SerializeFrom<typeof action> | undefined, which is reasonable but requires you to handle undefined everywhere. With the newer unstable_defineAction API in Remix v2.9+, you get slightly better inference.
Next.js Server Actions have a different challenge. The 'use server' boundary serializes and deserializes data, so you can't pass non-serializable types through. Dates become strings. Class instances don't survive. If you're passing complex objects, you'll need to re-parse on the other side — which is just something you have to know going in. Pairing this with Zod schemas helps a lot. See TypeScript tips for React apps for patterns that work well here.
Which has better DX? Honestly, Next.js Server Actions feel more ergonomic for TypeScript-first teams because you're just calling a typed function. Remix's model requires you to understand the serialization boundary and explicitly type your form data reads. Both are workable — Next.js just has less conceptual overhead for a TypeScript developer coming in fresh.
When to Pick Remix vs Next.js for Forms
So which one should you actually use? If you're building a content-heavy site where progressive enhancement matters, forms are the primary interaction model, and you want the framework to handle revalidation automatically — Remix is the natural fit. Things like admin dashboards with lots of forms, data-entry tools, or CMS-driven sites map perfectly to the Remix model.
If you're building a Next.js app already (which is most of the React ecosystem at this point), and your mutations are scattered across components rather than tied to specific routes, Server Actions are the pragmatic choice. You don't have to restructure your route hierarchy to get server-side mutations. Just write an async function.
Could you also just use a regular API route and fetch? Yes. And sometimes that's still the right call — especially when you need the same endpoint consumed by a mobile app or a third-party service. Don't let the framework abstractions push you into an architecture that doesn't fit. And whichever path you pick, pairing it with a component library that handles form styling consistently — like Empire UI's 40 visual styles — means you're not reinventing input components every project.
FAQ
Remix actions work without JavaScript by default — that's a core feature of the framework. Next.js Server Actions also degrade gracefully when used with a native <form action={serverAction}> pattern, but any mutations triggered from onClick handlers obviously require JS. If no-JS support matters to you, Remix's model is more reliable out of the box.
Not always. If you're using useActionState with a form, Next.js automatically revalidates the current route after the action runs. If you're calling a server action from an onClick outside a form, you may need to call router.refresh() or use revalidatePath() / revalidateTag() inside the action itself to bust the cache.
In Remix, you use unstable_parseMultipartFormData with a custom uploadHandler — there's a built-in memory handler and adapters for cloud storage. In Next.js, you read the file from formData.get('file') which returns a File object, then pipe it to your storage provider. Next.js's approach is slightly more familiar if you've used the Fetch API's FormData.
Yes to both. In Remix, parse formData entries and run them through a Zod schema inside your action function, returning validation errors as JSON. In Next.js, do the same inside your 'use server' function. The conform library (now @conform-to/react) is specifically designed for Remix but works with both, giving you schema-driven field-level errors with minimal boilerplate.
The closest equivalent is useActionData (for form submission results) combined with useNavigation (for pending state). useNavigation().state === 'submitting' tells you when a form is in-flight. For non-navigating mutations you'd use useFetcher, which has .state, .data, and .submit all in one hook — arguably cleaner than useActionState for complex cases.
Both are vulnerable to CSRF if you're not careful, though both frameworks handle same-origin form submissions safely by default. The bigger risk with Next.js Server Actions is accidentally exposing sensitive logic through the public action URL that Next.js generates under the hood. Always validate inputs server-side and check authorization inside the action function itself — never trust that the caller is authenticated just because the function is server-side.