Skip to main content

Progressive Enhancement with Server Actions

Progressive enhancement is a design philosophy where you build a core experience that works without JavaScript (plain HTML), then layer JavaScript on top to improve it—not replace it. Server Actions are perfect for this: a form with a Server Action works immediately without JavaScript, submitting via HTTP POST, and the page reloads with fresh data. Once JavaScript loads, you can intercept the submission, show optimistic updates, and prevent the reload, creating a seamless single-page-app experience.

This approach has three benefits: accessibility (works for users with JavaScript disabled or slow loading), resilience (if a JavaScript bundle fails to load, the form still functions), and SEO (search engines see a working form, not a broken JavaScript app). For most applications, a progressively enhanced form outperforms a client-only form in both performance and reliability.

How Progressive Enhancement Works with Server Actions

Here's the flow:

  1. No JavaScript (initial load): User sees a standard HTML form. They fill in fields and click submit. The browser sends a POST request with FormData to the Server Action endpoint. Next.js executes the Server Action, persists the data, and sends back a response (usually with a redirect). The browser loads the new page, showing the updated data.

  2. JavaScript loads: React mounts. The form is still a normal HTML form, but now React can intercept the form submission using the form's onSubmit event or by detecting that the form uses a Server Action.

  3. Enhanced experience: Instead of a page reload, your component can show a loading spinner, optimistic updates, and error messages inline. The form never actually navigates because React prevents it and handles the Server Action call directly.

All of this happens automatically with Server Actions—you don't need to write custom JavaScript to enable progressive enhancement. The HTML just works, and React enhances it.

Building a Basic Progressively Enhanced Form

Start with a simple HTML form:

// app/actions.ts
'use server';

import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';

export async function addTask(formData: FormData) {
const title = formData.get('title') as string;

if (!title.trim()) {
throw new Error('Task title is required');
}

await db.tasks.create({ title });
revalidatePath('/tasks');
}
// app/components/TaskForm.tsx
'use client';

import { addTask } from '@/app/actions';

export function TaskForm() {
return (
<form action={addTask}>
<input
type="text"
name="title"
placeholder="Add a task"
required
/>
<button type="submit">Add</button>
</form>
);
}

This form works immediately, without JavaScript. The browser submits to the Server Action endpoint. Next.js executes addTask, inserts the task, revalidates the cache for /tasks, and responds. The browser reloads the page, and the user sees the new task.

Enhancing with Loading and Error States

Once JavaScript loads, you can enhance the form by tracking submission state and preventing the default reload. Use useActionState for built-in error and pending tracking:

// app/components/TaskForm.tsx (enhanced)
'use client';

import { useActionState } from 'react';
import { addTask } from '@/app/actions';

export function TaskForm() {
const [state, formAction, isPending] = useActionState(addTask, {
error: null,
});

return (
<form action={formAction}>
<input
type="text"
name="title"
placeholder="Add a task"
required
disabled={isPending}
/>
<button type="submit" disabled={isPending}>
{isPending ? 'Adding...' : 'Add'}
</button>
{state?.error && (
<p style={{ color: 'red' }}>Error: {state.error}</p>
)}
</form>
);
}

Now the form:

  1. Without JavaScript: submits normally, page reloads
  2. With JavaScript: button shows "Adding...", input is disabled, and any error displays inline without a reload

The behavior is identical for the user—the form works either way. JavaScript just makes it feel snappier.

Handling Redirects in Progressive Enhancement

Server Actions can call redirect() to navigate after a mutation. Without JavaScript, this works like a normal HTTP redirect. With JavaScript, you need to handle the redirect on the client side.

Use useActionState and check if the action returned a redirect signal:

// app/actions.ts
'use server';

import { redirect } from 'next/navigation';

export async function createPost(formData: FormData) {
const title = formData.get('title') as string;

if (!title.trim()) {
throw new Error('Title is required');
}

const post = await db.posts.create({ title });

// Redirect will throw and break the normal flow
// With JavaScript, handle via client-side navigation
redirect(`/blog/${post.id}`);
}

When redirect() is called, it throws an error that Next.js catches. Without JavaScript, the browser sees the HTTP response and navigates. With JavaScript, you should use a client-side router to navigate after success:

// app/components/CreatePostForm.tsx
'use client';

import { useRouter } from 'next/navigation';
import { useActionState } from 'react';
import { createPost } from '@/app/actions';

export function CreatePostForm() {
const router = useRouter();
const [state, formAction, isPending] = useActionState(
async (formData: FormData) => {
try {
const result = await createPost(formData);
// If createPost redirects, this won't run.
// But if it returns a value, navigate here.
router.push(`/blog/${result.postId}`);
} catch (error) {
if (error instanceof Error && error.message.includes('NEXT_REDIRECT')) {
// Redirect threw; let the default behavior handle it
return;
}
return { error: String(error) };
}
},
{ error: null }
);

return (
<form action={formAction}>
<input type="text" name="title" placeholder="Title" required />
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create'}
</button>
{state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
</form>
);
}

Alternatively, return a value (not redirect) and navigate on the client:

// app/actions.ts
'use server';

export async function createPost(formData: FormData) {
const post = await db.posts.create({ /* ... */ });
return { postId: post.id }; // Return instead of redirect
}

Then navigate in the component using the returned value.

Validation and Error Messages

Progressive enhancement shines with validation. Validate on the server, and display errors inline without a page reload:

// app/actions.ts
'use server';

import { z } from 'zod';

const taskSchema = z.object({
title: z.string().min(1, 'Title is required').max(100, 'Title is too long'),
});

export async function addTask(formData: FormData) {
const title = formData.get('title') as string;

const result = taskSchema.safeParse({ title });
if (!result.success) {
throw new Error(result.error.errors[0].message);
}

await db.tasks.create({ title: result.data.title });
revalidatePath('/tasks');
}
// app/components/TaskForm.tsx
'use client';

import { useActionState } from 'react';
import { addTask } from '@/app/actions';

export function TaskForm() {
const [state, formAction, isPending] = useActionState(addTask, {
error: null,
});

return (
<form action={formAction}>
<input
type="text"
name="title"
placeholder="Add a task (max 100 chars)"
maxLength={100}
required
disabled={isPending}
/>
<button type="submit" disabled={isPending}>
{isPending ? 'Adding...' : 'Add'}
</button>
{state?.error && (
<p style={{ color: 'red' }}>{state.error}</p>
)}
</form>
);
}

The error message displays below the button, and the form remains visible for the user to correct and retry. No page reload, no client-side validation library—just server-side logic that returns errors naturally.

Key Takeaways

  • Progressive enhancement means building a working core (plain HTML) and layering JavaScript enhancements on top.
  • Server Actions in forms work without JavaScript because they're just HTML forms that POST to a backend endpoint.
  • Use useActionState to enhance forms with loading states, error messages, and disabled inputs without breaking the no-JavaScript experience.
  • Server-side validation is the single source of truth; return errors as part of the action's response to display them inline.
  • Progressive enhancement improves accessibility, resilience, and perceived performance because the form works immediately, even if JavaScript is slow or fails.

Frequently Asked Questions

How do I know if JavaScript has loaded in a progressively enhanced form?

You don't need to—the form works the same way either way. If JavaScript hasn't loaded, the form submits normally. If it has, React intercepts the submission. The experience is transparent to the user. Your component code should work in both cases automatically.

Can I use HTML5 validation (like required, type="email") with Server Actions?

Yes. HTML5 validation runs in the browser before submitting, which is good UX (instant feedback). Server-side validation is the security layer and the fallback. Use both: HTML5 for instant client feedback, server validation for security and error recovery.

What if the user disables JavaScript after the page loads?

The form still works because it's a normal HTML form. The browser will submit it normally, and the Server Action endpoint will process it. No special handling needed.

How do I handle file uploads in a progressively enhanced form?

FormData natively supports file inputs. Just add <input type="file" name="..."> to your form, and formData.get(name) will be a File object in your Server Action. See article 8 for detailed file handling.

Should I use formAction or a regular action prop?

Both work. The action prop (plain HTML) works without JavaScript. When you use useActionState, it returns a formAction that should replace the action prop on your form. useActionState automatically handles the submission and state management.

Further Reading