Back to Blog
ReactServer ActionsFormsNext.js

React Server Actions Guide

Master React Server Actions. From form handling to data mutations to progressive enhancement.

B
Bootspring Team
Engineering
December 27, 2020
7 min read

Server Actions enable server-side mutations directly from components. Here's how to use them.

Basic Server Action#

1// app/actions.ts 2'use server'; 3 4export async function createUser(formData: FormData) { 5 const name = formData.get('name') as string; 6 const email = formData.get('email') as string; 7 8 // Server-side validation 9 if (!name || !email) { 10 throw new Error('Name and email are required'); 11 } 12 13 // Database operation 14 const user = await db.user.create({ 15 data: { name, email }, 16 }); 17 18 return user; 19} 20 21// app/page.tsx 22import { createUser } from './actions'; 23 24export default function Page() { 25 return ( 26 <form action={createUser}> 27 <input name="name" placeholder="Name" required /> 28 <input name="email" type="email" placeholder="Email" required /> 29 <button type="submit">Create User</button> 30 </form> 31 ); 32}

With Form Validation#

1// app/actions.ts 2'use server'; 3 4import { z } from 'zod'; 5 6const userSchema = z.object({ 7 name: z.string().min(2, 'Name must be at least 2 characters'), 8 email: z.string().email('Invalid email address'), 9 password: z.string().min(8, 'Password must be at least 8 characters'), 10}); 11 12export type ActionState = { 13 errors?: { 14 name?: string[]; 15 email?: string[]; 16 password?: string[]; 17 _form?: string[]; 18 }; 19 success?: boolean; 20}; 21 22export async function createUser( 23 prevState: ActionState, 24 formData: FormData 25): Promise<ActionState> { 26 const rawData = { 27 name: formData.get('name'), 28 email: formData.get('email'), 29 password: formData.get('password'), 30 }; 31 32 const validationResult = userSchema.safeParse(rawData); 33 34 if (!validationResult.success) { 35 return { 36 errors: validationResult.error.flatten().fieldErrors, 37 }; 38 } 39 40 try { 41 await db.user.create({ 42 data: validationResult.data, 43 }); 44 45 return { success: true }; 46 } catch (error) { 47 return { 48 errors: { 49 _form: ['Failed to create user. Please try again.'], 50 }, 51 }; 52 } 53} 54 55// app/components/UserForm.tsx 56'use client'; 57 58import { useActionState } from 'react'; 59import { createUser, ActionState } from '../actions'; 60 61export function UserForm() { 62 const [state, formAction, isPending] = useActionState<ActionState, FormData>( 63 createUser, 64 { errors: {} } 65 ); 66 67 return ( 68 <form action={formAction}> 69 <div> 70 <input name="name" placeholder="Name" /> 71 {state.errors?.name && ( 72 <span className="error">{state.errors.name[0]}</span> 73 )} 74 </div> 75 76 <div> 77 <input name="email" type="email" placeholder="Email" /> 78 {state.errors?.email && ( 79 <span className="error">{state.errors.email[0]}</span> 80 )} 81 </div> 82 83 <div> 84 <input name="password" type="password" placeholder="Password" /> 85 {state.errors?.password && ( 86 <span className="error">{state.errors.password[0]}</span> 87 )} 88 </div> 89 90 {state.errors?._form && ( 91 <div className="error">{state.errors._form[0]}</div> 92 )} 93 94 {state.success && ( 95 <div className="success">User created successfully!</div> 96 )} 97 98 <button type="submit" disabled={isPending}> 99 {isPending ? 'Creating...' : 'Create User'} 100 </button> 101 </form> 102 ); 103}

useFormStatus#

1'use client'; 2 3import { useFormStatus } from 'react-dom'; 4 5function SubmitButton() { 6 const { pending, data, method, action } = useFormStatus(); 7 8 return ( 9 <button type="submit" disabled={pending}> 10 {pending ? 'Submitting...' : 'Submit'} 11 </button> 12 ); 13} 14 15function FormFields() { 16 const { pending } = useFormStatus(); 17 18 return ( 19 <fieldset disabled={pending}> 20 <input name="title" placeholder="Title" /> 21 <textarea name="content" placeholder="Content" /> 22 </fieldset> 23 ); 24} 25 26// Usage - SubmitButton must be inside form 27export function Form({ action }: { action: (formData: FormData) => Promise<void> }) { 28 return ( 29 <form action={action}> 30 <FormFields /> 31 <SubmitButton /> 32 </form> 33 ); 34}

Optimistic Updates#

1'use client'; 2 3import { useOptimistic } from 'react'; 4import { addTodo, deleteTodo } from './actions'; 5 6interface Todo { 7 id: string; 8 text: string; 9 completed: boolean; 10 pending?: boolean; 11} 12 13export function TodoList({ todos }: { todos: Todo[] }) { 14 const [optimisticTodos, addOptimisticTodo] = useOptimistic( 15 todos, 16 (state: Todo[], newTodo: Todo) => [...state, { ...newTodo, pending: true }] 17 ); 18 19 async function handleSubmit(formData: FormData) { 20 const text = formData.get('text') as string; 21 const tempId = `temp-${Date.now()}`; 22 23 // Optimistic update 24 addOptimisticTodo({ 25 id: tempId, 26 text, 27 completed: false, 28 }); 29 30 // Server action 31 await addTodo(formData); 32 } 33 34 return ( 35 <div> 36 <form action={handleSubmit}> 37 <input name="text" placeholder="New todo" /> 38 <button type="submit">Add</button> 39 </form> 40 41 <ul> 42 {optimisticTodos.map((todo) => ( 43 <li 44 key={todo.id} 45 style={{ opacity: todo.pending ? 0.5 : 1 }} 46 > 47 {todo.text} 48 </li> 49 ))} 50 </ul> 51 </div> 52 ); 53}

Revalidation#

1// app/actions.ts 2'use server'; 3 4import { revalidatePath, revalidateTag } from 'next/cache'; 5 6export async function createPost(formData: FormData) { 7 const title = formData.get('title') as string; 8 const content = formData.get('content') as string; 9 10 await db.post.create({ 11 data: { title, content }, 12 }); 13 14 // Revalidate specific path 15 revalidatePath('/posts'); 16 17 // Or revalidate by tag 18 revalidateTag('posts'); 19 20 // Revalidate with layout 21 revalidatePath('/posts', 'layout'); 22} 23 24export async function deletePost(id: string) { 25 await db.post.delete({ where: { id } }); 26 27 // Revalidate the posts list and specific post 28 revalidatePath('/posts'); 29 revalidatePath(`/posts/${id}`); 30}

Redirect After Action#

1// app/actions.ts 2'use server'; 3 4import { redirect } from 'next/navigation'; 5 6export async function createPost(formData: FormData) { 7 const post = await db.post.create({ 8 data: { 9 title: formData.get('title') as string, 10 content: formData.get('content') as string, 11 }, 12 }); 13 14 // Redirect to the new post 15 redirect(`/posts/${post.id}`); 16} 17 18export async function logout() { 19 await auth.signOut(); 20 redirect('/login'); 21}

File Uploads#

1// app/actions.ts 2'use server'; 3 4import { writeFile } from 'fs/promises'; 5import { join } from 'path'; 6 7export async function uploadFile(formData: FormData) { 8 const file = formData.get('file') as File; 9 10 if (!file || file.size === 0) { 11 return { error: 'No file uploaded' }; 12 } 13 14 // Validate file type 15 const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; 16 if (!allowedTypes.includes(file.type)) { 17 return { error: 'Invalid file type' }; 18 } 19 20 // Validate file size (5MB) 21 if (file.size > 5 * 1024 * 1024) { 22 return { error: 'File too large' }; 23 } 24 25 const bytes = await file.arrayBuffer(); 26 const buffer = Buffer.from(bytes); 27 28 const filename = `${Date.now()}-${file.name}`; 29 const path = join(process.cwd(), 'public', 'uploads', filename); 30 31 await writeFile(path, buffer); 32 33 return { success: true, path: `/uploads/${filename}` }; 34} 35 36// Client component 37'use client'; 38 39import { useRef, useState } from 'react'; 40import { uploadFile } from './actions'; 41 42export function FileUpload() { 43 const [preview, setPreview] = useState<string | null>(null); 44 const formRef = useRef<HTMLFormElement>(null); 45 46 function handleChange(e: React.ChangeEvent<HTMLInputElement>) { 47 const file = e.target.files?.[0]; 48 if (file) { 49 setPreview(URL.createObjectURL(file)); 50 } 51 } 52 53 async function handleSubmit(formData: FormData) { 54 const result = await uploadFile(formData); 55 56 if (result.success) { 57 formRef.current?.reset(); 58 setPreview(null); 59 } 60 } 61 62 return ( 63 <form ref={formRef} action={handleSubmit}> 64 <input 65 type="file" 66 name="file" 67 accept="image/*" 68 onChange={handleChange} 69 /> 70 {preview && <img src={preview} alt="Preview" />} 71 <button type="submit">Upload</button> 72 </form> 73 ); 74}

Error Handling#

1// app/actions.ts 2'use server'; 3 4class ActionError extends Error { 5 constructor( 6 message: string, 7 public code: string 8 ) { 9 super(message); 10 this.name = 'ActionError'; 11 } 12} 13 14export async function riskyAction(formData: FormData) { 15 try { 16 const result = await db.something.create({ 17 data: { /* ... */ }, 18 }); 19 return { success: true, data: result }; 20 } catch (error) { 21 if (error instanceof PrismaClientKnownRequestError) { 22 if (error.code === 'P2002') { 23 return { error: 'This item already exists' }; 24 } 25 } 26 return { error: 'Something went wrong' }; 27 } 28} 29 30// With error boundary 31// app/error.tsx 32'use client'; 33 34export default function Error({ 35 error, 36 reset, 37}: { 38 error: Error & { digest?: string }; 39 reset: () => void; 40}) { 41 return ( 42 <div> 43 <h2>Something went wrong!</h2> 44 <p>{error.message}</p> 45 <button onClick={reset}>Try again</button> 46 </div> 47 ); 48}

Inline Server Actions#

1// Define actions inline 2export default function Page() { 3 async function handleSubmit(formData: FormData) { 4 'use server'; 5 6 const title = formData.get('title'); 7 await db.post.create({ data: { title } }); 8 revalidatePath('/posts'); 9 } 10 11 return ( 12 <form action={handleSubmit}> 13 <input name="title" /> 14 <button type="submit">Create</button> 15 </form> 16 ); 17} 18 19// With closure over props 20export default function DeleteButton({ id }: { id: string }) { 21 async function handleDelete() { 22 'use server'; 23 24 await db.post.delete({ where: { id } }); 25 revalidatePath('/posts'); 26 } 27 28 return ( 29 <form action={handleDelete}> 30 <button type="submit">Delete</button> 31 </form> 32 ); 33}

Progressive Enhancement#

1// Works without JavaScript 2export default function ContactForm() { 3 async function sendMessage(formData: FormData) { 4 'use server'; 5 6 await sendEmail({ 7 to: 'contact@example.com', 8 subject: formData.get('subject') as string, 9 body: formData.get('message') as string, 10 }); 11 12 redirect('/thank-you'); 13 } 14 15 return ( 16 <form action={sendMessage}> 17 <input name="subject" placeholder="Subject" required /> 18 <textarea name="message" placeholder="Message" required /> 19 <button type="submit">Send</button> 20 </form> 21 ); 22}

Best Practices#

Security: ✓ Always validate input on server ✓ Sanitize user data ✓ Check authentication/authorization ✓ Use CSRF protection (built-in) Performance: ✓ Keep actions focused ✓ Use revalidation strategically ✓ Consider optimistic updates ✓ Handle loading states UX: ✓ Show pending states ✓ Provide error feedback ✓ Support progressive enhancement ✓ Handle edge cases Organization: ✓ Group related actions ✓ Use consistent error handling ✓ Type action returns ✓ Document complex actions

Conclusion#

Server Actions simplify data mutations by eliminating API routes. Use them for forms, mutations, and server-side operations. Combine with useActionState for validation, useOptimistic for instant feedback, and always validate inputs on the server.

Share this article

Help spread the word about Bootspring