Skip to main content
โšก Calmops

Server Actions Complete Guide: Next.js and Remix Data Mutations

Introduction

Server Actions are a paradigm shift in how we handle form submissions and data mutations in React applications. Instead of creating separate API endpoints, you define functions that run directly on the server and can be called from your components. This guide covers Server Actions in both Next.js and Remix.

What are Server Actions?

Server Actions let you define functions that:

  • Run on the server
  • Can be called from client components
  • Handle form submissions
  • Return data to the client
  • Integrate with server-side validation
graph TB
    subgraph "Traditional Approach"
        Client1[Client]
        API[API Route]
        Server1[Server]
        
        Client1 -->|POST| API
        API --> Server1
    end
    
    subgraph "Server Actions"
        Client2[Client]
        Action[Server Action]
        
        Client2 -->|call| Action
    end

Next.js Server Actions

Defining Server Actions

// app/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string
  
  // Validate
  if (!title || title.length < 3) {
    return { error: 'Title must be at least 3 characters' }
  }
  
  // Save to database
  const post = await db.post.create({
    title,
    content,
  })
  
  // Revalidate and redirect
  revalidatePath('/posts')
  redirect(`/posts/${post.id}`)
}

Using in Components

// app/components/CreatePost.tsx
'use client'

import { createPost } from '@/app/actions'

export function CreatePost() {
  return (
    <form action={createPost}>
      <input 
        type="text" 
        name="title" 
        placeholder="Post title"
        required 
      />
      <textarea 
        name="content" 
        placeholder="Post content"
      />
      <button type="submit">Create Post</button>
    </form>
  )
}

Validation with Zod

// app/actions.ts
'use server'

import { z } from 'zod'

const schema = z.object({
  title: z.string().min(3).max(100),
  email: z.string().email(),
  age: z.number().min(18).optional(),
})

export async function submitForm(prevState: any, formData: FormData) {
  // Extract and validate
  const data = {
    title: formData.get('title'),
    email: formData.get('email'),
    age: formData.get('age') ? Number(formData.get('age')) : undefined,
  }
  
  const result = schema.safeParse(data)
  
  if (!result.success) {
    return {
      errors: result.error.flatten().fieldErrors,
    }
  }
  
  // Process valid data
  await db.user.create(result.data)
  
  return { success: true }
}

useFormState Hook

// app/components/Form.tsx
'use client'

import { useFormState } from 'react-dom'
import { submitForm } from '@/app/actions'

const initialState = {
  message: '',
  errors: null,
}

export function Form() {
  const [state, formAction] = useFormState(submitForm, initialState)
  
  return (
    <form action={formAction}>
      <div>
        <label>Title</label>
        <input type="text" name="title" />
        {state?.errors?.title && (
          <p className="error">{state.errors.title}</p>
        )}
      </div>
      
      <button type="submit">Submit</button>
      
      {state?.message && <p>{state.message}</p>}
    </form>
  )
}

useFormStatus

// app/components/SubmitButton.tsx
'use client'

import { useFormStatus } from 'react-dom'

export function SubmitButton() {
  const { pending } = useFormStatus()
  
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Submitting...' : 'Submit'}
    </button>
  )
}

Mutations with Loading States

// Using useTransition
'use client'

import { updatePost } from '@/app/actions'
import { useState, useTransition } from 'react'

export function EditPost({ post }) {
  const [isPending, startTransition] = useTransition()
  const [title, setTitle] = useState(post.title)
  
  function handleSubmit(e) {
    e.preventDefault()
    
    startTransition(async () => {
      await updatePost(post.id, { title })
    })
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <input 
        value={title} 
        onChange={(e) => setTitle(e.target.value)}
      />
      <button disabled={isPending}>
        {isPending ? 'Saving...' : 'Save'}
      </button>
    </form>
  )
}

Revalidation

'use server'

import { revalidatePath } from 'next/cache'
import { revalidateTag } from 'next/cache'

export async function updatePost(id: string, data: PostData) {
  await db.post.update(id, data)
  
  // Revalidate specific path
  revalidatePath(`/posts/${id}`)
  revalidatePath('/posts')
  
  // Or revalidate by tag
  revalidateTag('posts')
}

Remix Server Actions

Basic Actions

// app/routes/posts.new.tsx
import { ActionFunctionArgs, json, redirect } from '@remix-run/node'
import { Form, useActionData } from '@remix-run/react'

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData()
  
  const title = formData.get('title') as string
  const content = formData.get('content') as string
  
  // Validation
  const errors: Record<string, string> = {}
  if (!title || title.length < 3) {
    errors.title = 'Title must be at least 3 characters'
  }
  
  if (Object.keys(errors).length > 0) {
    return json({ errors }, { status: 400 })
  }
  
  // Create post
  const post = await db.post.create({ title, content })
  
  return redirect(`/posts/${post.id}`)
}

export default function NewPost() {
  const actionData = useActionData<typeof action>()
  
  return (
    <Form method="post">
      <div>
        <label>Title</label>
        <input type="text" name="title" />
        {actionData?.errors?.title && (
          <span>{actionData.errors.title}</span>
        )}
      </div>
      
      <div>
        <label>Content</label>
        <textarea name="content" />
      </div>
      
      <button type="submit">Create</button>
    </Form>
  )
}

useSubmit Hook

// app/components/Search.tsx
import { useSubmit } from '@remix-run/react'

export function Search() {
  const submit = useSubmit()
  
  function handleChange(event) {
    const isFirstSearch = event.currentTarget.value === ''
    submit(event.currentTarget.form, {
      replace: !isFirstSearch,
    })
  }
  
  return (
    <Form method="get">
      <input
        name="q"
        type="search"
        onChange={handleChange}
      />
    </Form>
  )
}

Actions with File Upload

// app/routes/upload.tsx
import { 
  ActionFunctionArgs, 
  unstable_parseMultipartFormData,
  unstable_createMemoryUploadHandler,
} from '@remix-run/node'

export async function action({ request }: ActionFunctionArgs) {
  const uploadHandler = unstable_createMemoryUploadHandler({
    maxPartSize: 5_000_000, // 5MB
  })
  
  const formData = await unstable_parseMultipartFormData(
    request,
    uploadHandler
  )
  
  const file = formData.get('avatar') as File
  
  // Process file...
  await saveFile(file)
  
  return json({ success: true })
}

Error Handling

// Using ErrorBoundary
export function ErrorBoundary() {
  const error = useRouteError()
  
  if (error instanceof Response) {
    return (
      <div>
        <h1>{error.status} Error</h1>
        <p>{error.statusText}</p>
      </div>
    )
  }
  
  return <div>Unknown error occurred</div>
}

Comparison

Feature Next.js Remix
API ‘use server’ directive action export
Form Handling Server Actions Form component
Validation useFormState useActionData
Pending States useFormStatus useNavigation
Redirect redirect() redirect()
Revalidation revalidatePath invalidator

Best Practices

1. Always Validate on Server

// โœ… Good - Server handles validation
export async function createUser(formData: FormData) {
  const email = formData.get('email')
  
  if (!email || !isValidEmail(email)) {
    return { error: 'Invalid email' }
  }
  
  await db.user.create({ email })
}

// โŒ Bad - Trusting client validation
export async function createUser(formData: FormData) {
  const email = formData.get('email') // Could be anything!
  await db.user.create({ email })
}

2. Handle Errors Gracefully

export async function createPost(formData: FormData) {
  try {
    await db.post.create({...})
    revalidatePath('/posts')
    return { success: true }
  } catch (error) {
    return { 
      error: 'Failed to create post',
      details: error instanceof Error ? error.message : 'Unknown error'
    }
  }
}

3. Use Progressive Enhancement

// Works without JavaScript!
<Form action={createPost}>
  <input name="title" required />
  <button type="submit">Create</button>
</Form>

4. Optimize for Mutation

// Use optimistic UI
export function LikeButton({ post }) {
  const { pending } = useFetcher()
  
  const isPending = pending || pendingSubmit
  
  return (
    <fetcher.Form method="post" action="/like">
      <input type="hidden" name="postId" value={post.id} />
      <button 
        disabled={isPending}
        className={isPending ? 'loading' : ''}
      >
        {post.likes + (isPending ? 1 : 0)} Likes
      </button>
    </fetcher.Form>
  )
}

Conclusion

Server Actions simplify data mutations:

  • No API routes - mutations directly on server
  • Type-safe - full TypeScript support
  • Progressive enhancement - works without JS
  • Built-in validation - validate on server

Both Next.js and Remix offer excellent Server Action support. Choose based on your framework preference.


External Resources

Comments