Stop Using GET Inside Server Actions

Learn why using GET inside Next.js Server Actions creates unnecessary round trips, how to fetch data efficiently with React Server Components, and when to use Server Actions the right way โ€” with fun analogies and diagrams. - 8/25/2025

6 min read

views

The logo of Stop Using GET Inside Server Actions

๐Ÿš€ Stop Using GET Inside Server Actions

TL;DR: Using GET requests inside Server Actions creates unnecessary round trips and hurts performance. Use React Server Components for data fetching and Server Actions only for mutations (POST/PUT/DELETE).

If youโ€™re diving into Next.js App Router and experimenting with Server Actions, you might be tempted to wrap every fetch in them โ€” including GET requests.

But hereโ€™s the catch: GET inside Server Actions is like calling your mom to ask if you can text her. Totally unnecessary. ๐Ÿ™ƒ

This guide will show you why this pattern hurts performance, when to use each approach, and how to migrate existing code for better user experience.

Letโ€™s break it down.


โšก Server Actions 101

Server Actions are special async functions that run on the server. You usually trigger them via:

  • Form submissions
  • Button clicks
  • startTransition hooks

Theyโ€™re serialized, sent over the wire, and executed in the server runtime.

๐Ÿ‘‰ Why POST? Server Actions use POST requests for security and consistency. Unlike GET requests (which can be cached, bookmarked, or logged), POST requests ensure mutations are intentional and canโ€™t be accidentally triggered by web crawlers or browser prefetching.

// actions.ts
"use server";
 
export async function addTodo(formData: FormData) {
  const title = formData.get("title");
 
  await fetch("https://api.example.com/todos", {
    method: "POST",
    body: JSON.stringify({ title }),
  });
}

โŒ The GET Trap

Now imagine you try this:

"use server";
 
export async function getTodos() {
  const res = await fetch("https://api.example.com/todos");
  return res.json();
}

And then call it from your component:

'use client';
 
import { useState, useEffect } from 'react';
import { getTodos } from "./actions";
 
interface Todo {
  id: string;
  title: string;
  completed: boolean;
}
 
export default function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    getTodos()
      .then(setTodos)
      .catch((err) => setError(err.message))
      .finally(() => setLoading(false));
  }, []);
 
  if (loading) return <div>Loading todos...</div>;
  if (error) return <div>Error: {error}</div>;
 
  return <pre>{JSON.stringify(todos, null, 2)}</pre>;
}

๐Ÿ’ฅ Problem: You just created two round trips.

  1. Client โ†’ Server Action (POST request to run getTodos)
  2. Server Action โ†’ API (GET request for todos)

This is slower than if you had just fetched the data directly in a React Server Component.


๐Ÿ” Visualizing the Double Round Trip

Hereโ€™s a simple diagram to show the problem:

ClientServer ActionAPIPOST (run getTodos)GET /todostodos JSONtodos JSON

โšก Notice how the client goes through an extra middleman. Thatโ€™s wasted time.


๐Ÿ“Š Performance Impact

Letโ€™s put some numbers to this problem:

ApproachRound TripsTypical TimeCaching
Server Action + GET2~400-800msโŒ No caching
React Server Component1~200-400msโœ… Built-in caching

Real-world example:

  • Server Action approach: 600ms (300ms clientโ†’server + 300ms serverโ†’API)
  • RSC approach: 250ms (direct serverโ†’API at render time)

Thatโ€™s a 58% performance improvement just by using the right pattern! ๐Ÿš€

The performance gap widens with:

  • Slower networks (mobile users suffer more)
  • Geographic distance (extra hop compounds latency)
  • Heavy payloads (serialization overhead doubles)

โœ… The Right Way to Fetch Data

In React Server Components, you can fetch directly. No extra hops.

// app/todos/page.tsx
interface Todo {
  id: string;
  title: string;
  completed: boolean;
}
 
export default async function TodosPage() {
  const todos: Todo[] = await fetch("https://api.example.com/todos", {
    cache: 'force-cache', // Cache for 1 hour
    next: { revalidate: 3600 }
  }).then((res) => {
    if (!res.ok) throw new Error('Failed to fetch todos');
    return res.json();
  });
 
  return (
    <div>
      <h1>My Todos</h1>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id} className={todo.completed ? 'completed' : ''}>
            {todo.title}
          </li>
        ))}
      </ul>
    </div>
  );
}

โšก This happens at render time on the server, streamed to the client. Super efficient.

Key benefits:

  • โœ… Built-in caching with Next.js fetch extensions
  • โœ… Automatic revalidation keeps data fresh
  • โœ… Streaming support for better perceived performance
  • โœ… Error boundaries can catch and handle failures gracefully

๐ŸŽฏ When to Use Server Actions

  • Mutations (POST/PUT/PATCH/DELETE)

    • Adding a todo
    • Deleting a user
    • Updating profile settings
  • Never for pure GETs (unless youโ€™re doing something weird like combining data after a mutation).

Example mutation with UI:

// app/todos/page.tsx
import { addTodo } from "./actions";
 
export default function TodosPage() {
  return (
    <form action={addTodo}>
      <input name="title" placeholder="New Todo" />
      <button type="submit">Add</button>
    </form>
  );
}

Thatโ€™s the sweet spot for Server Actions. ๐Ÿš€


๐Ÿค” Edge Cases: When GET in Server Actions Might Be OK

While rare, there are a few scenarios where GET in Server Actions could be justified:

1. Data Transformation After Mutation

"use server";
 
export async function createUserAndFetchDashboard(formData: FormData) {
  // First, create the user (mutation)
  const user = await createUser(formData);
  
  // Then fetch their personalized dashboard data
  // This GET is acceptable because it depends on the mutation result
  const dashboard = await fetch(`/api/dashboard/${user.id}`);
  
  return { user, dashboard: await dashboard.json() };
}

2. Combining Multiple Data Sources

"use server";
 
export async function searchAndFilter(query: string, filters: string[]) {
  // Complex server-side logic that combines multiple APIs
  const [searchResults, userPrefs, recommendations] = await Promise.all([
    fetch(`/api/search?q=${query}`),
    fetch(`/api/user-preferences`),
    fetch(`/api/recommendations`)
  ]);
  
  // Server-side filtering logic too complex for client
  return combineAndFilter(searchResults, userPrefs, recommendations, filters);
}

3. Security-Sensitive Operations

"use server";
 
export async function getAdminData(userId: string) {
  // Server-side permission checking before data access
  const hasPermission = await checkAdminPermissions(userId);
  if (!hasPermission) throw new Error('Unauthorized');
  
  return fetch('/api/admin/sensitive-data');
}

โš ๏ธ Rule of thumb: Only use GET in Server Actions when you need server-side logic that canโ€™t be done in RSCs.


๐Ÿš€ Migration Guide

Already using GET in Server Actions? Hereโ€™s how to migrate:

Step 1: Identify GET Server Actions

# Search your codebase for the pattern
grep -r "use server" --include="*.ts" --include="*.tsx" . | xargs grep -l "fetch.*GET\|fetch.*method.*GET"

Step 2: Move to Server Components

// โŒ Before: GET in Server Action
"use server";
export async function getUser(id: string) {
  return fetch(`/api/users/${id}`).then(res => res.json());
}
 
// โœ… After: Direct fetch in RSC
export default async function UserProfile({ id }: { id: string }) {
  const user = await fetch(`/api/users/${id}`, {
    next: { revalidate: 300 } // Cache for 5 minutes
  }).then(res => res.json());
  
  return <div>{user.name}</div>;
}

Step 3: Update Client Components

// โŒ Before: Calling Server Action from client
'use client';
export function UserDisplay({ id }: { id: string }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    getUser(id).then(setUser); // Server Action call
  }, [id]);
  
  return user ? <div>{user.name}</div> : <div>Loading...</div>;
}
 
// โœ… After: Pass data from Server Component
export default async function UserPage({ id }: { id: string }) {
  const user = await fetch(`/api/users/${id}`).then(res => res.json());
  
  return <UserDisplay user={user} />; // Pass as prop
}
 
'use client';
export function UserDisplay({ user }: { user: User }) {
  return <div>{user.name}</div>; // No loading state needed!
}

Step 4: Handle Dynamic Data

For data that changes frequently, use revalidatePath or revalidateTag:

// actions.ts
"use server";
import { revalidatePath } from 'next/cache';
 
export async function updateUser(formData: FormData) {
  await updateUserInDB(formData);
  revalidatePath('/profile'); // Refresh the cached data
}

๐ŸŽ‰ TL;DR

  • GET โ†’ Use React Server Components
  • POST โ†’ Use Server Actions
  • Donโ€™t wrap GETs in Server Actions unless you like extra lag ๐Ÿ˜…

๐Ÿช Fun Analogy

Think of it like this:

  • GET in RSC โ†’ Walking straight to the fridge to grab cookies.
  • GET in Server Action โ†’ Calling your roommate to ask them to go to the fridge, grab cookies, and bring them to you. Then they call the bakery to bake cookies first. ๐Ÿ™ƒ

One is instant. The other? A comedy of delays.

So next time youโ€™re tempted to use GET in a Server Actionโ€ฆ just walk to the fridge yourself. ๐Ÿชโœจ



Tags: Next.js, React, Server Actions, Performance, App Router, Server Components, Web Development