JONATHAN FREIRE

May 05, 2026

Difuminando la línea entre cliente y servidor con Server Actions

Difuminando la línea entre cliente y servidor con Server Actions

Este artículo muestra cómo simplificar tu arquitectura usando server actions. No voy a explicar en detalle sus pros y contras. Las considero una herramienta ya madura, y son una forma excelente de gestionar la comunicación entre cliente y servidor.

Las server actions son una forma de ejecutar código del lado del servidor en tus componentes de React. Next.js gestiona esta acción como una petición POST serializando los datos antes de la request. Después, deserializa los datos en el lado del servidor y ejecuta el código de la acción. Por último, Next.js serializa la respuesta en el servidor y la deserializa en el cliente.

Una server action se parece a una función normal que puedes llamar directamente en un componente de React, por lo que es muy habitual acabar con código desordenado. La situación empeora si incluyes la lógica de negocio directamente en la server action, en el mismo archivo.

Hay 3 partes importantes en tu código: los componentes de React, la herramienta para hacer peticiones al servidor desde el cliente y la lógica de negocio que se ejecuta en el servidor. Las server actions son la segunda, así que puedes verlas como una alternativa a usar tRPC o «fetch/axios + API Routes». Si combinas todas estas partes en el mismo archivo, te encontrarás con muchos problemas, como una base de código difícil de mantener o un acoplamiento fuerte.

Para evitar estos inconvenientes, merece la pena dedicar un momento a pensar en tu arquitectura. He preparado este artículo para ayudarte a implementar las mejores prácticas, incluyendo procedures, revalidación de caché, actualizaciones optimistas y más.

Vamos a crear una pequeña app de recomendaciones de libros. Para centrarnos únicamente en las server actions, empezaremos el proyecto usando una plantilla que he creado, que utiliza Next.js, Prisma, Shadcn y Better Auth. Puedes crear un nuevo proyecto en GitHub usando esta plantilla accediendo a este repositorio.

Qué cubre esta guía

  1. Configuración del proyecto
  2. Modelado de la base de datos
  3. Estructura de carpetas
  4. Validación de inputs
  5. Services
  6. Queries y caché
  7. Procedures públicos y protegidos
  8. Server actions y revalidación de caché
  9. React Hook Form y actions
  10. Reseñas de libros
  11. Dar un like
  12. Conclusión

Configuración del proyecto

Como ya hemos mencionado, empezaremos el proyecto usando este repositorio como plantilla. Si prefieres comenzar desde cero y configurar tú mismo todas las tecnologías que vamos a usar, te recomiendo seguir mi último artículo.

Para configurar el proyecto, debes seguir la sección Getting Started del archivo README.md incluido en la plantilla, así que no la repetiré aquí.

Una vez completados los pasos, verás que no solo está corriendo tu app, sino que también tienes una base de datos local configurada. Además, esta plantilla ya incluye autenticación básica por correo electrónico y contraseña.

Modelado de la base de datos

El objetivo principal de esta app es tener un tablón público de reseñas de libros. Sin embargo, solo los usuarios autenticados pueden crear una reseña o dar un like. Este último requisito nos obliga a tener un procedure protegido para hacer peticiones al backend.

El sistema de autenticación ya está implementado en la plantilla, así que solo necesitamos crear 2 modelos en nuestro Prisma Schema actual: BookReview y Like.

prisma
model User {
  ...otherProperties
 
  bookReviews   BookReview[]
  likes         Like[]
 
  @@unique([email])
  @@map("user")
}
 
model BookReview {
  id         String   @id @default(cuid())
  title      String
  author     String
  buyUrl     String?
  content    String   @db.Text
  likesCount Int      @default(0)
  createdAt  DateTime @default(now())
  updatedAt  DateTime @updatedAt
  likes      Like[]
 
  user   User?   @relation(fields: [userId], references: [id], onDelete: SetNull)
  userId String?
 
  @@map("book_review")
}
 
model Like {
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
 
  user         User       @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId       String
  bookReview   BookReview @relation(fields: [bookReviewId], references: [id], onDelete: Cascade)
  bookReviewId String
 
  @@id([userId, bookReviewId])
  @@map("like")
}

Después, ejecuta una nueva migración y regenera el Prisma Client en tu terminal:

bash
pnpm dlx prisma migrate dev --name add-book-reviews-and-likes
pnpm dlx prisma generate

Estructura de carpetas

Además del resto de carpetas del proyecto, crearemos una nueva carpeta /server en la raíz. Recuerda que la carpeta /app es específicamente para el routing y los layouts, así que no recomiendo tener esta nueva carpeta dentro de /app. Dentro de la carpeta /server, tendremos la misma estructura de carpetas organizada por entidad. Voy a mostrarte una estructura inicial, aunque puedes ir más al detalle.

text
/server
  ├── book-reviews
    ├── book-reviews.actions.ts
    ├── book-reviews.queries.ts
    ├── book-reviews.service.ts
    ├── book-reviews.schema.ts
  ├── likes
    ├── likes.actions.ts
    ├── likes.queries.ts
    ├── likes.service.ts
    ├── likes.schema.ts

Siguiendo el Single Responsibility Principle (SRP), mi sugerencia es tener estos 3 archivos por entidad:

  • El archivo actions tendrá la directiva "use server" para indicarle a Next.js que este archivo se ejecutará en el servidor. Cada función de este archivo es una server action y se encarga de la validación del input, la autenticación del usuario, etc. Recuerda: las server actions son básicamente mutaciones.
  • El archivo queries puede usarse directamente en un server component para obtener datos. A diferencia de las Server Actions, son una capa de solo lectura que aprovecha "use cache" y cacheTag de Next.js para un alto rendimiento, manteniendo la lógica de negocio desacoplada y portable. Recuerda: si necesitas llamar a una query desde un client component usando, por ejemplo, fetch o react-query, tienes que crear una action que llame a esa query.
  • El archivo service es donde vive la lógica de negocio. Las funciones de este archivo son reutilizables en distintas actions o queries. No cambia si cambia la comunicación con el cliente.
  • El archivo schema contiene los schemas de Zod para validar los inputs de tus actions o funciones de servicio.

Validación de inputs

Tenemos que crear las funciones CRUD para las reseñas de libros, pero nos centraremos únicamente en crear y leer reseñas. Además, necesitamos gestionar los likes de las reseñas y actualizar su contador. Por ello, primero hay que crear los schemas de zod para validar los inputs de estas actions.

Empezando con las reseñas de libros, necesitamos un schema de validación para el input de creación. Pero también necesitamos otro para consultar reseñas y especificar cuántas queremos recibir.

typescript
import { z } from "zod";
 
export const createBookReviewSchema = z.object({
  title: z.string().min(1, "El título es obligatorio"),
  author: z.string().min(1, "El autor es obligatorio"),
  buyUrl: z.url("La URL debe ser válida").nullable(),
  content: z.string().min(1, "El contenido es obligatorio"),
});
 
export const getAllBookReviewsSchema = z.object({
  skip: z.number().min(0).default(0),
  take: z.number().min(1).max(100).default(10),
});

A continuación, como hemos mencionado, necesitamos una action para alternar el botón de like de una reseña, pero también una query para obtener el estado del botón de like por parte del usuario. Creemos los schemas de validación para el input.

typescript
import { z } from "zod";
 
export const getLikesStatusSchema = z.object({
  bookReviewId: z.cuid("ID de reseña no válido"),
  userId: z.string().min(1, "El ID de usuario es obligatorio"),
});
 
export const toogleLikeSchema = z.object({
  bookReviewId: z.cuid("ID de reseña no válido"),
});

Services

Es el momento de crear los services donde vive la lógica de negocio. Para evitar que las funciones se usen accidentalmente directamente en un client component, es una buena práctica declarar estos archivos como server-only. Para ello, hay que instalar la siguiente dependencia:

bash
pnpm add server-only

Al importarla al principio de estos archivos, se producirá un error de compilación si usas alguna función de estos archivos en el cliente.

Empezando con el service de reseñas de libros, tenemos que crear una función para crear una reseña y otra para obtener todas las reseñas. Observa que los tipos de input de cada función se infieren a partir de los schemas creados anteriormente. Los datos ya están validados, bien en las actions o en las queries. Dentro de estas funciones, usamos el cliente de base de datos, que en este caso es el Prisma Client.

typescript
import "server-only";
import { z } from "zod";
import { db } from "@/lib/db";
import {
  createBookReviewSchema,
  getAllBookReviewsSchema,
} from "./book-reviews.schema";
 
export const createBookReview = async (
  input: z.infer<typeof createBookReviewSchema> & { userId: string },
) => {
  const bookReview = await db.bookReview.create({
    data: input,
  });
 
  return bookReview;
};
 
export const getAllBookReviews = async (
  input: z.infer<typeof getAllBookReviewsSchema>,
) => {
  const { skip, take } = input;
  const bookReviews = await db.bookReview.findMany({
    skip,
    take,
    orderBy: {
      createdAt: "desc",
    },
  });
 
  const total = await db.bookReview.count();
 
  return {
    data: bookReviews,
    hasNextPage: skip + take < total,
  };
};

Después, aplicamos la misma lógica para crear el service de likes. Es importante mencionar que la creación o eliminación de un like y la actualización de la reseña se realizan en la misma transacción, asegurándonos de que ambas operaciones se completan o ninguna lo hace.

typescript
import "server-only";
import { db } from "@/lib/db";
import { z } from "zod";
import { getLikesStatusSchema, toogleLikeSchema } from "./likes.schema";
 
export const getLikesStatus = async ({
  bookReviewId,
  userId,
}: z.infer<typeof getLikesStatusSchema>) => {
  const userLike = await db.like.findFirst({
    where: {
      userId,
      bookReviewId,
    },
  });
 
  return !!userLike;
};
 
export const toogleLike = async (
  input: z.infer<typeof toogleLikeSchema> & { userId: string },
) => {
  const { userId, bookReviewId } = input;
 
  const existingLike = await db.like.findFirst({
    where: {
      userId,
      bookReviewId,
    },
  });
 
  if (existingLike) {
    await db.$transaction([
      db.like.delete({
        where: {
          userId_bookReviewId: {
            userId,
            bookReviewId,
          },
        },
      }),
      db.bookReview.update({
        where: {
          id: bookReviewId,
        },
        data: {
          likesCount: {
            decrement: 1,
          },
        },
      }),
    ]);
 
    return { liked: false };
  } else {
    await db.$transaction([
      db.like.create({
        data: {
          userId,
          bookReviewId,
        },
      }),
      db.bookReview.update({
        where: {
          id: bookReviewId,
        },
        data: {
          likesCount: {
            increment: 1,
          },
        },
      }),
    ]);
 
    return { liked: true };
  }
};

Queries y caché

Una vez creada la lógica de negocio, podemos centrarnos en la siguiente capa. Estas funciones envuelven nuestros services principales para gestionar la obtención de datos y el rendimiento, aprovechando "use cache" y cacheTag de Next.js.

typescript
import "server-only";
import { getAllBookReviews } from "./book-reviews.service";
import { getAllBookReviewsSchema } from "./book-reviews.schema";
import { z } from "zod";
import { cacheTag } from "next/cache";
 
export const getAllBookReviewsQuery = async (
  input: z.infer<typeof getAllBookReviewsSchema>,
) => {
  "use cache";
  cacheTag("reviews-list");
 
  const parsedInput = getAllBookReviewsSchema.parse(input);
 
  return getAllBookReviews(parsedInput);
};

Como puedes ver, validamos el input en estas funciones porque las queries se usan directamente en server components. Si necesitas crear una server action que use una query, debes decidir si validar el input en la query o en tu server action, teniendo en cuenta que las server actions están en una capa superior.

typescript
import "server-only";
import { cacheTag } from "next/cache";
import { getLikesStatusSchema } from "./likes.schema";
import { z } from "zod";
import { getSession } from "@/lib/better-auth/server";
import { getLikesStatus } from "./likes.service";
 
export const getLikesStatusQuery = async ({
  bookReviewId,
}: {
  bookReviewId: string;
}) => {
  const session = await getSession();
 
  if (!session) return null;
 
  const parsedInput = getLikesStatusSchema.parse({
    bookReviewId,
    userId: session.user.id,
  });
 
  return getCachedLikesStatus(parsedInput);
};
 
const getCachedLikesStatus = async ({
  bookReviewId,
  userId,
}: z.infer<typeof getLikesStatusSchema>) => {
  "use cache";
  cacheTag(`like-status-${bookReviewId}-${userId}`);
 
  return getLikesStatus({
    bookReviewId,
    userId,
  });
};

Procedures públicos y protegidos

Cuando creas server actions, te encontrarás con frecuencia con la necesidad de añadir middlewares. En nuestro caso, tenemos que añadir 2: uno para la autenticación del usuario y otro para la validación del input usando los schemas creados anteriormente. Pero, en el futuro, necesitarás añadir más middlewares para logging, añadir metadatos, etc. Para conseguirlo, hay que añadir la siguiente dependencia:

bash
pnpm add next-safe-action

A continuación, creamos un action client para gestionar los errores y añadir un delay para simular latencia de red en modo desarrollo. De este modo, crearemos ambos procedures usando este action client.

typescript
import { createSafeActionClient } from "next-safe-action";
import { getSession } from "@/lib/better-auth/server";
 
/**
 * Helper para simular latencia de red en modo desarrollo
 */
const simulateDelay = async () => {
  if (process.env.NODE_ENV === "development") {
    await new Promise((resolve) => setTimeout(resolve, 1000));
  }
};
 
/**
 * Cliente base con middleware global para logging y delay
 */
export const actionClient = createSafeActionClient({
  // Manejador global de errores
  handleServerError: (error) => {
    console.error("Error del servidor:", error);
    return error.message || "Ha ocurrido un error inesperado";
  },
}).use(async ({ next }) => {
  await simulateDelay();
  return next();
});
 
/**
 * Procedure: Acción pública
 * Disponible para todos, pero incluye el delay global y la validación
 */
export const publicProcedure = actionClient;
 
/**
 * Procedure: Acción protegida
 * Verifica la sesión del usuario antes de ejecutar la lógica de la acción
 */
export const protectedProcedure = actionClient.use(async ({ next }) => {
  const session = await getSession();
 
  if (!session?.user) {
    throw new Error(
      "No autorizado: debes estar autenticado para realizar esta acción",
    );
  }
 
  // Inyecta la sesión del usuario en el contexto (ctx)
  return next({
    ctx: {
      user: session.user,
      session: session,
    },
  });
});

Server actions y revalidación de caché

Necesitamos una server action para crear reseñas de libros y otra para alternar el botón de like. Para ambas actions, usaremos el procedure protegido, validaremos el input y usaremos las funciones del service correspondiente.

typescript
"use server";
import { protectedProcedure } from "@/lib/safe-action";
import { createBookReview } from "./book-reviews.service";
import { createBookReviewSchema } from "./book-reviews.schema";
import { updateTag } from "next/cache";
 
export const createBookReviewAction = protectedProcedure
  .inputSchema(createBookReviewSchema)
  .action(async ({ parsedInput, ctx }) => {
    const { user } = ctx;
 
    const bookReview = await createBookReview({
      ...parsedInput,
      userId: user.id,
    });
 
    updateTag("reviews-list");
 
    return bookReview;
  });
typescript
"use server";
import { protectedProcedure } from "@/lib/safe-action";
import { toogleLike } from "./likes.service";
import { toogleLikeSchema } from "./likes.schema";
import { updateTag } from "next/cache";
 
export const toogleLikeAction = protectedProcedure
  .inputSchema(toogleLikeSchema)
  .action(async ({ parsedInput, ctx }) => {
    const { user } = ctx;
    const { bookReviewId } = parsedInput;
 
    const res = await toogleLike({
      userId: user.id,
      bookReviewId,
    });
 
    updateTag(`like-status-${bookReviewId}-${user.id}`);
    updateTag(`reviews-list`);
 
    return res;
  });

Como habrás notado, actualizamos los datos en caché especificando los cache tags. Personalmente prefiero esta estrategia a usar revalidatePath, que invalida los datos en caché para una ruta específica. Es un enfoque interesante que también uso a veces, pero es muy habitual obtener los mismos datos en distintas páginas. Por ejemplo, si usas la query getAllBookReviewsQuery en una nueva página, tendrás que recordar invalidar también los datos en caché de esa nueva página. Usando updateTag, te aseguras de que los datos en caché se actualicen en toda la app.

React Hook Form y actions

En el lado del cliente, la primera página que creamos es la de crear una reseña de libro. Contiene un formulario que usa React Hook Form, lo que te permite validar los datos en el cliente y mostrar feedback al usuario. Al enviar el formulario validado, llamaremos a nuestra server action createBookReviewAction.

tsx
"use client";
 
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { createBookReviewAction } from "@/server/book-reviews/book-reviews.actions";
import { createBookReviewSchema } from "@/server/book-reviews/book-reviews.schema";
import { Button } from "./ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "./ui/form";
import { Input } from "./ui/input";
import { Textarea } from "./ui/textarea";
 
type CreateReviewFormValues = z.input<typeof createBookReviewSchema>;
 
export function CreateReviewForm() {
  const router = useRouter();
 
  const form = useForm<CreateReviewFormValues>({
    resolver: zodResolver(createBookReviewSchema),
    defaultValues: {
      title: "",
      author: "",
      buyUrl: null,
      content: "",
    },
  });
 
  const isSubmitting = form.formState.isSubmitting;
 
  async function onSubmit(values: CreateReviewFormValues) {
    const result = await createBookReviewAction({
      ...values,
      buyUrl: values.buyUrl || null,
    });
 
    if (result?.data) {
      toast.success("¡Reseña creada con éxito!", {
        position: "bottom-right",
      });
      form.reset();
      router.push("/");
      return;
    }
 
    const errorMessage =
      result?.serverError ||
      "No se ha podido crear la reseña. Inténtalo de nuevo.";
 
    toast.error("Error al crear la reseña", {
      description: errorMessage,
      position: "bottom-right",
    });
  }
 
  return (
    <Card className="w-full max-w-2xl">
      <CardHeader>
        <CardTitle>Crear reseña de libro</CardTitle>
      </CardHeader>
      <CardContent>
        <Form {...form}>
          <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
            <FormField
              control={form.control}
              name="title"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Título</FormLabel>
                  <FormControl>
                    <Input
                      placeholder="Título del libro"
                      autoComplete="off"
                      {...field}
                    />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
 
            <FormField
              control={form.control}
              name="author"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Autor</FormLabel>
                  <FormControl>
                    <Input
                      placeholder="Nombre del autor"
                      autoComplete="off"
                      {...field}
                    />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
 
            <FormField
              control={form.control}
              name="buyUrl"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>URL de compra (opcional)</FormLabel>
                  <FormControl>
                    <Input
                      type="url"
                      placeholder="https://ejemplo.com/libro"
                      autoComplete="off"
                      value={field.value ?? ""}
                      onChange={(event) => {
                        const value = event.target.value.trim();
                        field.onChange(value === "" ? null : value);
                      }}
                    />
                  </FormControl>
                  <FormDescription>
                    Proporciona una URL válida donde se pueda comprar este
                    libro.
                  </FormDescription>
                  <FormMessage />
                </FormItem>
              )}
            />
 
            <FormField
              control={form.control}
              name="content"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Contenido de la reseña</FormLabel>
                  <FormControl>
                    <Textarea
                      placeholder="Escribe tu reseña..."
                      className="min-h-40"
                      {...field}
                    />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
 
            <Button
              type="submit"
              disabled={isSubmitting}
              className="w-full sm:w-auto"
            >
              {isSubmitting ? "Creando..." : "Crear reseña"}
            </Button>
          </form>
        </Form>
      </CardContent>
    </Card>
  );
}
tsx
import { redirect } from "next/navigation";
import { CreateReviewForm } from "@/components/create-review-form";
import { getSession } from "@/lib/better-auth/server";
 
export default async function CreateBookReviewPage() {
  const session = await getSession();
 
  const isLoggedIn = !!session?.session;
 
  if (!isLoggedIn) {
    redirect("/auth/login");
  }
 
  return (
    <div className="flex flex-1 items-center justify-center">
      <CreateReviewForm />
    </div>
  );
}

Reseñas de libros

Una vez que tenemos una forma de crear reseñas, podemos crear el tablón de reseñas. Teniendo en cuenta que podríamos tener cientos de reseñas, mostraremos una lista paginada.

tsx
import { ReviewCard } from "@/components/review-card";
import { ReviewsBoardShell } from "@/components/reviews-board-shell";
import { ReviewsPagination } from "@/components/reviews-pagination";
import { getAllBookReviewsQuery } from "@/server/book-reviews/book-reviews.queries";
 
const TAKE = 3;
 
interface HomePageProps {
  searchParams: Promise<{ page?: string }>;
}
 
export default async function HomePage({ searchParams }: HomePageProps) {
  const resolvedSearchParams = await searchParams;
  const pageParam = Number.parseInt(resolvedSearchParams.page ?? "1", 10);
  const page = Number.isNaN(pageParam) || pageParam < 1 ? 1 : pageParam;
  const skip = (page - 1) * TAKE;
 
  const reviews = await getAllBookReviewsQuery({ skip, take: TAKE });
 
  return (
    <ReviewsBoardShell>
      <section className="mb-10">
        <h1 className="text-4xl tracking-tight text-zinc-950 sm:text-5xl">
          The library card
        </h1>
        <p className="mt-3 max-w-2xl text-sm leading-7 text-zinc-600">
          A curated stream of book reflections from readers, thinkers, and
          storytellers.
        </p>
      </section>
 
      {reviews.data.length === 0 ? (
        <div className="flex min-h-64 items-center justify-center rounded-xl border border-zinc-200 bg-zinc-50">
          <p className="text-zinc-600">
            Aún no hay reseñas. Sé el primero en escribir una.
          </p>
        </div>
      ) : (
        <section className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
          {reviews.data.map((review) => (
            <ReviewCard key={review.id} review={review} />
          ))}
        </section>
      )}
 
      <ReviewsPagination currentPage={page} hasNextPage={reviews.hasNextPage} />
    </ReviewsBoardShell>
  );
}

Observa que usamos una estrategia de skip-and-take para obtener una lista de 3 reseñas. El parámetro de búsqueda page determina el valor de skip. También podríamos implementar la misma lógica totalmente en el cliente usando estados, pero es mejor práctica gestionar la paginación a través de la URL. Esto permite a los motores de búsqueda indexar todas tus páginas de reseñas, y además podemos implementarlo directamente en el server component usando la query getAllBookReviewsQuery. Solo los botones de paginación son del lado del cliente, lo que permite cambiar de página haciendo clic en ellos. Estos botones están incluidos en el componente ReviewsPagination disponible en el repositorio de este proyecto, pero no son relevantes para este artículo. Pasemos a la siguiente sección.

Dar un like

Primero, necesitamos mostrar el estado del botón de like usando la query getLikesStatusQuery en el server component ReviewCard y pasando el valor al botón de like ReviewLike.

tsx
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { BookReview } from "@/lib/generated/prisma/client";
import { getLikesStatusQuery } from "@/server/likes/likes.queries";
import { ReviewCardMotion } from "./review-card-motion";
import { ReviewLike } from "./review-like";
 
interface ReviewCardProps {
  review: BookReview;
}
 
export async function ReviewCard({ review }: ReviewCardProps) {
  const isLiked = await getLikesStatusQuery({ bookReviewId: review.id });
 
  return (
    <ReviewCardMotion>
      <Card className="border-zinc-200 bg-white/70 backdrop-blur-sm">
        <CardHeader className="space-y-2">
          <p className="text-xs tracking-[0.22em] text-zinc-500 uppercase">
            {review.author}
          </p>
          <CardTitle className="text-xl leading-tight text-zinc-950">
            {review.title}
          </CardTitle>
        </CardHeader>
 
        <CardContent className="space-y-5">
          <p className="line-clamp-6 text-sm leading-7 text-zinc-700">
            {review.content}
          </p>
 
          <div className="flex items-center justify-between border-t border-zinc-200 pt-4">
            <ReviewLike
              bookReviewId={review.id}
              isLiked={!!isLiked}
              likesCount={review.likesCount}
              isAuthenticated={isLiked !== null}
            />
 
            {review.buyUrl ? (
              <a
                href={review.buyUrl}
                target="_blank"
                rel="noreferrer noopener"
                className="text-xs font-medium tracking-wide text-zinc-600 underline-offset-4 hover:text-zinc-900 hover:underline"
              >
                Comprar libro
              </a>
            ) : null}
          </div>
        </CardContent>
      </Card>
    </ReviewCardMotion>
  );
}

Ahora tenemos que crear el componente ReviewLike, que se encarga de mostrar el valor del like, pero también de alternarlo. Centrándonos en esta última tarea, primero hay que tener en cuenta que ReviewLike es un client component, porque tiene que escuchar el clic en el botón. Por tanto, tenemos varias formas de llamar a la server action.

La forma más sencilla de alternar el valor del like es usar toogleLikeAction directamente. Sin embargo, es una buena práctica deshabilitar el botón mientras se ejecuta la mutación. Por eso recomiendo usar el hook useAction de next-safe-action.

No obstante, hay otro problema. En producción, habrá un pequeño retraso al cambiar el estado del like. El usuario podría interpretarlo como un bug e intentar pulsar el botón de nuevo. Un botón de like necesita cambiar de forma instantánea y fluida. La librería next-safe-action también tiene el hook useOptimisticAction, que usaremos en este caso.

tsx
"use client";
 
import { toogleLikeAction } from "@/server/likes/likes.actions";
import { HeartIcon } from "lucide-react";
import { motion } from "motion/react";
import { useOptimisticAction } from "next-safe-action/hooks";
import { useRouter } from "next/navigation";
import { Button } from "./ui/button";
 
interface ReviewLikeProps {
  bookReviewId: string;
  isLiked: boolean;
  likesCount: number;
  isAuthenticated: boolean;
}
 
export function ReviewLike({
  bookReviewId,
  isLiked,
  likesCount,
  isAuthenticated,
}: ReviewLikeProps) {
  const router = useRouter();
 
  const { execute, optimisticState, isExecuting } = useOptimisticAction(
    toogleLikeAction,
    {
      currentState: {
        isLiked,
        likesCount,
      },
      updateFn: (state) => {
        if (state.isLiked) {
          return {
            isLiked: false,
            likesCount: likesCount - 1,
          };
        } else {
          return {
            isLiked: true,
            likesCount: likesCount + 1,
          };
        }
      },
    },
  );
 
  const handleToggleLike = async () => {
    if (!isAuthenticated) {
      router.push("/auth/login");
      return;
    }
 
    execute({ bookReviewId });
  };
 
  return (
    <Button
      type="button"
      variant="ghost"
      className="text-muted-foreground hover:text-foreground h-auto px-0"
      onClick={handleToggleLike}
      disabled={isExecuting}
      aria-label={
        optimisticState.isLiked
          ? "Quitar like a la reseña"
          : "Dar like a la reseña"
      }
    >
      <motion.span
        key={optimisticState.isLiked ? "liked" : "unliked"}
        initial={{ scale: 1 }}
        animate={
          optimisticState.isLiked
            ? { scale: [1, 1.25, 1] }
            : { scale: [1, 0.9, 1] }
        }
        transition={{ duration: 0.25, ease: "easeOut" }}
        className="mr-2 inline-flex"
      >
        <HeartIcon
          className={`size-4 ${optimisticState.isLiked ? "fill-foreground text-foreground" : ""}`}
        />
      </motion.span>
      <span className="text-sm">{optimisticState.likesCount}</span>
    </Button>
  );
}

Al usar este hook, definimos un comportamiento por defecto de la server action toogleLikeAction modificando el estado por defecto. Así, el usuario ve el valor optimista al instante. Después, si la respuesta es satisfactoria, el nuevo valor se actualizará en el servidor para todas las páginas, por lo que el usuario verá el mismo valor que el optimista. Sin embargo, si la server action lanza un error, el hook restaurará el valor al anterior.

Conclusión

Las server actions son un paso adelante por parte de Next.js. El principio rector es servir tus componentes desde el servidor por defecto y desde el cliente cuando sea necesario. Recomiendo fomentar su integración en tu app siempre que uses el app router de Next.js.

Por supuesto, hay muchas cosas a tener en cuenta para implementar una buena arquitectura con server actions. Pero ocurre lo mismo con React Query, donde también hay que gestionar correctamente la caché y las revalidaciones en el cliente. Después llegó tRPC para darnos una mejor arquitectura para llamar a las mismas queries y mutaciones tanto desde el cliente como desde el servidor de forma segura.

La arquitectura que he mostrado en este artículo cubre las mismas capacidades que tRPC, pero en el lado del servidor, ya que tRPC está construido sobre React Query. Comparto el enlace al repositorio para que puedas profundizar en él.