Next.js 14 Server Actions: A Practical Guide for Frontend Developers

Deep dive into Next.js 14 Server Actions with practical examples. Learn how to build efficient, server-side mutations in your React applications.


Introduction

Server Actions are revolutionizing how we handle server-side mutations in Next.js applications. Let's explore practical implementations with real code examples.

Basic Server Action Implementation

Form Handling

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

export async function createTodo(formData: FormData) {
  const title = formData.get('title')

  await prisma.todo.create({
    data: {
      title: title as string,
      completed: false
    }
  })
}

// app/components/TodoForm.tsx
export default function TodoForm() {
return (

<form action={createTodo}>
  <input type="text" name="title" required />
  <button type="submit">Add Todo</button>
</form>
) } ```

### With Client Validation

```typescript
'use client'

import { useFormState, useFormStatus } from "react-dom";
import { createTodo } from "@/app/actions";

function SubmitButton() {
const { pending } = useFormStatus()

return (

<button disabled={pending}>{pending ? "Adding..." : "Add Todo"}</button>) }

export default function TodoFormWithValidation() {
  const [state, formAction] = useFormState(createTodo, null)

  return (
    <form action={formAction}>
      <input type="text" name="title" required minLength={3} />
      <SubmitButton />
      {state?.error && <p className="text-red-500">{state.error}</p>}
    </form>
  )
}

Advanced Patterns

Optimistic Updates

'use client'

import { experimental_useOptimistic as useOptimistic } from 'react'

export function TodoList({ todos }) {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (state, newTodo) => [...state, newTodo]
  )

async function action(formData: FormData) {
const title = formData.get('title')

    addOptimisticTodo({ id: 'temp', title, completed: false })
    await createTodo(formData)

}

return (

<div>
  <form action={action}>
    <input type="text" name="title" />
    <button type="submit">Add</button>
  </form>
  <ul>
    {optimisticTodos.map((todo) => (
      <li key={todo.id}>{todo.title}</li>
    ))}
  </ul>
</div>
) } ```

### With Error Boundaries

```typescript
'use client'

import { useCallback } from "react";
import { ErrorBoundary } from "react-error-boundary";

function ErrorFallback({ error, resetErrorBoundary }) {
return (

<div role="alert" className="p-4 bg-red-50 rounded">
  <p className="text-red-800">Something went wrong:</p>
  <pre className="text-sm">{error.message}</pre>
  <button onClick={resetErrorBoundary}>Try again</button>
</div>
) }

export function TodoFormWithErrorHandling() {
  const onReset = useCallback(() => {
    // Reset form state
  }, [])

return (

<ErrorBoundary FallbackComponent={ErrorFallback} onReset={onReset}>
  <TodoForm />
</ErrorBoundary>
) } ```

## Real-World Examples

### File Upload with Progress

```typescript
'use server'

import { createPresignedPost } from "@aws-sdk/s3-presigned-post";

export async function getUploadUrl(filename: string) {
  const { url, fields } = await createPresignedPost(s3Client, {
    Bucket: "my-bucket",
    Key: filename,
    Expires: 600,
  });

return { url, fields };
}

// Client Component
'use client'

export function FileUploader() {
  const [progress, setProgress] = useState(0)

async function uploadFile(formData: FormData) {
const file = formData.get('file') as File
const { url, fields } = await getUploadUrl(file.name)

    const data = new FormData()
    Object.entries(fields).forEach(([key, value]) => {
      data.append(key, value as string)
    })
    data.append('file', file)

    await fetch(url, {
      method: 'POST',
      body: data,
      onUploadProgress: (e) => {
        setProgress((e.loaded / e.total) * 100)
      },
    })

}

return (

<form action={uploadFile}>
  <input type="file" name="file" />
  <button type="submit">Upload</button>
  {progress > 0 && <progress value={progress} max="100" />}
</form>
) } ```

### Real-time Search with Debounce

```typescript
'use server'

export async function searchProducts(query: string) {
  return await prisma.product.findMany({
    where: {
      name: {
        contains: query,
        mode: "insensitive",
      },
    },
    take: 10,
  });
}

// Client Component
'use client'

import { useCallback, useState } from 'react'
import { debounce } from 'lodash'

export function SearchProducts() {
  const [results, setResults] = useState([])

const debouncedSearch = useCallback(
debounce(async (query: string) => {
const products = await searchProducts(query)
setResults(products)
}, 300),
[]
)

return (

<div>
  <input
    type="text"
    onChange={(e) => debouncedSearch(e.target.value)}
    className="border p-2 rounded"
    placeholder="Search products..."
  />
  <ul className="mt-4">
    {results.map((product) => (
      <li key={product.id}>{product.name}</li>
    ))}
  </ul>
</div>
) } ```

## Performance Optimization

### Caching Strategies

```typescript
'use server'

import { cache } from "react";

export const getUser = cache(async (userId: string) => {
  const user = await prisma.user.findUnique({
    where: { id: userId },
  });
  return user;
});

// Usage in component
export default async function UserProfile({ userId }: { userId: string }) {
const user = await getUser(userId)

return (

<div>
  <h1>{user.name}</h1>
  {/* Other user details */}
</div>
) } ```

## Conclusion

Server Actions provide a powerful way to handle server-side mutations in Next.js applications. By following these patterns and examples, you can build more efficient and maintainable applications.

Key takeaways:

- Use Server Actions for form submissions and data mutations
- Implement optimistic updates for better UX
- Handle errors gracefully with Error Boundaries
- Optimize performance with caching

Remember to check the [Next.js documentation](https://nextjs.org/docs) for the latest updates and best practices.