DEV Community

Cover image for Dive into Next.js Server Actions: Simplifying Data Fetching & Mutations
Nik Bogachenkov
Nik Bogachenkov

Posted on

Dive into Next.js Server Actions: Simplifying Data Fetching & Mutations

In previous sections, we dove into SSR and Server Components. Now, let’s explore another powerful tool in Next.js — Server Actions.

Server Actions introduce a completely new way of handling data. Instead of making traditional API calls from the client to the server and then updating the client-side state, you can now call server-side functions directly from your components. This eliminates many unnecessary steps, reduces network requests, and improves performance.

How to Create a Server Action

Server Actions are defined using the "use server" directive. There are two primary ways to define them:

Inside a function. A simple and convenient approach is to place a Server Action directly within server components:

// @/components/Catalog.tsx
export default function Catalog() {
  async function deleteProduct() {
    "use server"
    // ...
  }
  return (
    // ...
  )
}
Enter fullscreen mode Exit fullscreen mode

In a separate file. This approach allows Server Actions to be used inside both client and server components:

// @/app/actions/loadProducts.tsx
"use server"

export async function deleteProduct() {}
Enter fullscreen mode Exit fullscreen mode

Using Server Actions for Mutations

Let’s say you need to delete a product from the catalog. Traditionally, you would send a DELETE request via an API, process the response, and update the interface:

import type { NextApiRequest, NextApiResponse } from 'next'
import { createClient } from "@/utils/supabase";

type ResponseData = {
  products: Product[];
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<ResponseData>
) {
  const requestMethod = req.method;
  switch (requestMethod) {
    case "GET":
      // ...
    case "DELETE":
      const { product_id } = req.body;
      await supabase
        .from("products")
        .delete()
        .eq("id", product_id);
      res.status(200).json({ message: "Success" });
      break;
    default:
      res.setHeader('Allow', ['GET', 'POST', 'DELETE']);
      res.status(405).end(`Method ${req.method} Not Allowed`);
  }
}
Enter fullscreen mode Exit fullscreen mode
export default function Catalog() {
  const { data: products, mutate } = useSWR('/api/catalog', fetcher);

  const handleDelete = async (product_id: string) => {
    try {
      const response = await fetch(
        '/api/catalog',
        {
          method: 'DELETE',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({ product_id }),
        }
      );
      if (response.ok) {
        // revalidate cache
        mutate();
      } else {
        // show error message
      }
    } catch (err) {
      // do something with error
    }
  }

  return (
    <ProductList>
      {products.map(p => (
        <ProductCard key={p.id} onDelete={() => handleDelete(p.id)} />
      ))}
    </ProductList>
  )
}
Enter fullscreen mode Exit fullscreen mode

With Server Actions, it’s much simpler:

// @/app/actions/deleteProduct.tsx
"use server";
import { createClient } from "@/utils/supabase";

export const deleteProduct = async (product_id: string) => {
  const supabase = createClient();
  await supabase
        .from("products")
        .delete()
        .eq("id", product_id);
  // Revalidating the Next.js Cache
  revalidatePath('/catalog');
}
Enter fullscreen mode Exit fullscreen mode

And here’s what the component would look like:

import getAllProducts from "app/actions/getAllProducts";
import deleteProduct from "app/actions/deleteProduct";

export default async function Catalog() {
  const products = await getAllProducts();

  return (
    <ProductList>
      {products.map(p => (
        <ProductCard key={p.id} onDelete={() => deleteProduct(p.id)} />
      ))}
    </ProductList>
  )
}
Enter fullscreen mode Exit fullscreen mode

Now, there’s no need to worry about making client-side requests—everything happens server-side.

Cache Management: revalidatePath and revalidateTag

After performing a mutation, you need to update the cache so that the changes are reflected in the UI. Next.js provides two functions for this:

  • revalidatePath—refreshes the cache for a specific path. For example, if a product is deleted, you would need to revalidate the /catalog page to display up-to-date data.
  • revalidateTag—refreshes a specific piece of data (rather than the whole page) using a tag:
// @/components/Catalog.tsx
// We fetch catalog data with a fetch request and assign a tag
export default async function Catalog() {
  const { data: products } = await fetch(
    "https://someproject.supabase.co/rest/v1/products?select=*",
    {
      next: {
        tags: ["catalog"]
      }
    }
  )
  return (
    // ...
  )
}

// Then we can revalidate our request within a Server Action
"use server";
import { createClient } from "@/utils/supabase/server";
import { revalidateTag } from "next/cache";

export const deleteProduct = async (product_id: string) => {
  await supabase
    .from("products")
    .delete()
    .eq("id", product_id);
  revalidateTag('catalog');
}
Enter fullscreen mode Exit fullscreen mode

Fetching Data with Server Actions

While Server Actions are often introduced as tools for data mutations, they can also be used to fetch data directly from the server. Here’s an example:

// app/actions/loadProducts.tsx
"use server"
export async function loadProducts() {
  // Fetch data from server or DB
}
Enter fullscreen mode Exit fullscreen mode

Then use it in a component:

// components/Catalog.tsx
import { loadProducts } from '@/actions/loadProducts.tsx';

export default async function Catalog() {
  const products = await loadProducts();
  return (
    // Render the product catalog
  )
}
Enter fullscreen mode Exit fullscreen mode

Finally, we add the component to the catalog page using Suspense to handle the loading state:

// app/catalog/page.tsx
import { Suspense } from "react";
import Catalog from "@/components/Catalog";

export default async function CatalogPage() {
  return (
    <Suspense fallback={<CatalogSkeleton />}>
      <Catalog />
    </Suspense>
  )
}
Enter fullscreen mode Exit fullscreen mode

At first glance, this may seem like a powerful way to handle data, but there are two important things to keep in mind:

1. Server Actions are executed sequentially

This is one of the biggest limitations: each Server Action is executed strictly one after another. This means that if you are making several server requests (e.g., fetching products, categories, and recommendations), they cannot happen in parallel. Unlike the standard fetch where you can perform multiple parallel requests and await their completion simultaneously, here the sequential execution may slow down the response time. In performance-critical scenarios, this could become an issue.

2. Server Action requests are always POST requests

Another key point is that all Server Action requests, regardless of their purpose, use the POST method. While this may not be critical in most cases, traditional API routes in Next.js offer more flexibility—they can accept GET requests, enabling caching and more efficient load distribution.

Ultimately, the choice is yours. Server Actions simplify the architecture of your app, eliminating the need for API routes for both mutations and even data fetching. But if you require parallel data loading or cacheable GET requests, traditional API routes might still be more suitable.

Using Server Actions with Forms

Server Actions can significantly simplify form handling in React by allowing HTML forms to directly connect to server-side logic. In Next.js, this is implemented by passing a Server Action as the form’s action. Once the form is submitted, the server function receives the FormData object and can process it like any other server-side operation. Here’s an example:

// app/actions/createProduct.tsx
"use server";

export default async function createProduct(formData: FormData) {
  const { data, error } = await supabase
    .from('products')
    .insert({
      title: formData.get("title"),
      description: formData.get("description"),
      price: +formData.get("price"),
      category_id: +formData.get("category_id")
    });

  redirect(`/dashboard/products/${data.id}`);
}
Enter fullscreen mode Exit fullscreen mode

In this example, createProduct takes the form data, saves the new product in the database, and then redirects the user to the newly created product’s page. Now let’s see how this Server Action integrates with a form:

// app/components/forms/CreateProduct.tsx
"use client";
import createProduct from '@/actions/createProduct';
import { useFormStatus } from "react-dom";

export default function CreateProductForm() {
  const { pending } = useFormStatus();

  return (
    <form action={createProduct}>
      // ...
      <Button isLoading={pending}>
        Create product
      </Button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

As you can see, the Server Action is simply passed into the form’s action attribute. When the form is submitted, it asynchronously runs the server function createProduct. However, there are a few key things to note:

Asynchronous handling with useFormStatus

React added support for tracking form status through the useFormStatus hook. This hook returns an object containing a pending field, which indicates whether the form submission is in progress. This is useful for showing loading indicators and preventing duplicate submissions while the form is being processed.

POST Requests and Redirects

Just like with data fetching, forms using Server Actions are always submitted via POST. This means you can’t cache these requests, but for most form operations (like creating or updating data), caching isn’t necessary anyway. Plus, Server Actions unlock the ability to perform server-side redirects, as seen in our example with the redirect function.

Adding Server Actions to forms makes handling them incredibly simple and elegant, reducing the amount of client-side code and improving your app’s architecture. However, it’s crucial to remember that these requests are always processed sequentially and must use the POST method. You’ll need to weigh this when deciding between Server Actions and more traditional API routes.


Server Actions significantly streamline how you handle data, eliminating the need for client-side API calls and letting you perform server-side operations directly from your components. This reduces app complexity and lightens the client’s load, ultimately leading to a faster and more responsive user experience.

But how does this tool fit into the larger Next.js architecture, and how does it interact with its other powerful features? To answer that, we need to dive into the App Router. In the next section, we’ll explore how the App Router helps you create more flexible routes, simplifies page management, and boosts performance with technologies like parallel rendering and nested layouts.

Top comments (0)