Skip to content

Next: Server Actions

Los formularios le permiten crear y actualizar datos en aplicaciones web. Next.js proporciona una forma poderosa de manejar envíos de formularios y mutaciones de datos mediante Server Actions.

⭐ Videos Premium ⭐

Esta sección es parte del curso en Udemy. Si quieres acceder a ella, puedes comprar el curso en Udemy: React + Firebase by bluuweb.

Código Github

Server Actions

  • server-actions
  • Las acciones de servidor es una característica alpha en Next.js (hoy agosto 2023).
  • Permiten mutar datos desde el servidor, reduciendo el Javascript en el cliente.
  • Se pueden definir en componentes de servidor y/o llamar desde componentes de cliente.

Ejemplo componente servidor:

tsx
import { cookies } from "next/headers";

// Server action defined inside a Server Component
export default function AddToCart({ productId }) {
  async function addItem(data) {
    "use server";

    const cartId = cookies().get("cartId")?.value;
    await saveToDb({ cartId, data });
  }

  return (
    <form action={addItem}>
      <button type="submit">Add to Cart</button>
    </form>
  );
}

Ejemplo componente cliente:

tsx
"use server";

export async function addItem(data) {
  const cartId = cookies().get("cartId")?.value;
  await saveToDb({ cartId, data });
}
tsx
"use client";

import { addItem } from "./actions.js";

// Server Action being called inside a Client Component
export default function AddToCart({ productId }) {
  return (
    <form action={addItem}>
      <button type="submit">Add to Cart</button>
    </form>
  );
}

Server Actions

En lugar de tener que crear manualmente un punto final de API, Server Actions crea automáticamente un punto final para que Next.js lo use detrás de escena.

Al llamar a una acción del servidor, Next.js envía una solicitud POST a la página en la que se encuentra con metadatos para qué acción ejecutar.

Instalar Next.js

sh
npx create-next-app@latest
yarn create next-app

Habilitar Server Actions

next.config.js

ts
module.exports = {
  experimental: {
    serverActions: true,
  },
};

Prisma

  • Prisma es un ORM (Object Relational Mapper) para Node.js y Typescript.
  • Prisma se utiliza para simplificar y gestionar las operaciones de base de datos, como consultas y manipulaciones, a través de una interfaz de programación en lugar de escribir consultas SQL directamente.
  • Permite trabajar con bases de datos SQL y NoSQL.
  • database-typescript-mongodb
sh
npm install prisma -D
yarn add prisma -DE

Prisma Schema

sh
npx prisma init

Este comando hace dos cosas:

  1. crea un nuevo directorio llamado prismaque contiene un archivo llamado schema.prisma, que contiene el esquema de Prisma con la variable de conexión de su base de datos y los modelos de esquema.
  2. crea el archivo .env en el directorio raíz del proyecto, que se utiliza para definir variables de entorno (como la conexión de su base de datos)

prisma\schema.prisma

ts
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mongodb"
  url      = env("DATABASE_URL")
}

model Todo {
  id    String @id @default(auto()) @map("_id") @db.ObjectId
  title String
}

Prisma Client

sh
npm install @prisma/client
yarn add @prisma/client

Siempre que actualice su esquema Prisma, deberá ejecutar el comando prisma db push para crear nuevos índices y regenerar Prisma Client.

libs\prismadb.ts

  • Exportar una instancia de prisma que será utilizada en otros módulos para interactuar con la base de datos.
  • Si globalForPrisma.prisma ya está definido en el objeto global, utiliza esa instancia. Si no existe, crea una nueva instancia de PrismaClient y la asigna a prisma. Esto garantiza que solo haya una instancia de PrismaClient en toda la aplicación.
ts
import { PrismaClient } from "@prisma/client";

const globalForPrisma = global as unknown as { prisma: PrismaClient };

export const prisma = globalForPrisma.prisma || new PrismaClient();

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

Server Action

app\todo\page.tsx

tsx
import { prisma } from "@/libs/prismadb";

const TodoPage = async () => {
  const todos = await prisma.todo.findMany();

  const createTodo = async (data: FormData) => {
    "use server";
    const title = data.get("title") as string;
    await prisma.todo.create({ data: { title } });
  };

  return (
    <div>
      <h1 className="text-center text-3xl my-10">TodoPage</h1>
      <form action={createTodo}>
        <input
          type="text"
          name="title"
          className="border border-gray-400 rounded p-2 mr-2"
        />
        <button
          type="submit"
          className="border border-gray-400 rounded p-2"
        >
          Add
        </button>
      </form>
      <pre>{JSON.stringify(todos, null, 2)}</pre>
    </div>
  );
};
export default TodoPage;

revalidatePath

ts
import { revalidatePath } from "next/cache";
...
const createTodo = async (data: FormData) => {
  "use server";
  const title = data.get("title") as string;
  await prisma.todo.create({ data: { title } });
  revalidatePath("/todo");
};

Server Action Client Components

app\todo\actions\todo.action.ts

ts
"use server";

import { prisma } from "@/libs/prismadb";
import { revalidatePath } from "next/cache";

export const createTodo = async (title: string) => {
  await prisma.todo.create({ data: { title } });
  revalidatePath("/todo");
};

app\todo\components\form.todo.tsx

tsx
"use client";

import { createTodo } from "../actions/todo.action";

const FormTodo = () => {
  const handleSubmit = async (data: FormData) => {
    const title = data.get("title") as string;
    await createTodo(title);
  };

  return (
    <form action={handleSubmit}>
      <input
        type="text"
        name="title"
        className="border border-gray-400 rounded p-2 mr-2"
      />
      <button
        type="submit"
        className="border border-gray-400 rounded p-2"
      >
        Add
      </button>
    </form>
  );
};
export default FormTodo;

app\todo\page.tsx

tsx
import { prisma } from "@/libs/prismadb";
import FormTodo from "./components/form.todo";

const TodoPage = async () => {
  const todos = await prisma.todo.findMany();

  return (
    <div>
      <h1 className="text-center text-3xl my-10">TodoPage</h1>
      <FormTodo />
      <pre>{JSON.stringify(todos, null, 2)}</pre>
    </div>
  );
};
export default TodoPage;

useRef (reset form)

app\todo\components\form.todo.tsx

tsx
"use client";

import { useRef } from "react";
import { createTodo } from "../actions/todo.action";

const FormTodo = () => {
  const formRef = useRef<HTMLFormElement>(null);

  const handleSubmit = async (data: FormData) => {
    const title = data.get("title") as string;
    await createTodo(title);
    formRef.current?.reset();
  };

  return (
    <form
      ref={formRef}
      action={handleSubmit}
      className="flex"
    >
      <input
        type="text"
        name="title"
        className="border border-gray-400 rounded p-2 mr-2"
      />
      <button
        type="submit"
        className="border border-gray-400 rounded w-28"
      >
        Add
      </button>
    </form>
  );
};
export default FormTodo;

React icons

sh
yarn add react-icons

useFormStatus

app\todo\components\button-form.todo.tsx

tsx
"use client";

import { experimental_useFormStatus as useFormStatus } from "react-dom";
import { FaSpinner } from "react-icons/fa";

const ButtonForm = () => {
  const { pending } = useFormStatus();

  return (
    <button
      type="submit"
      className="border border-gray-400 rounded w-28 grid place-items-center"
      disabled={pending}
    >
      {pending ? (
        <span className="block animate-spin">
          <FaSpinner className="transform rotate-90" />
        </span>
      ) : (
        "Submit"
      )}
    </button>
  );
};
export default ButtonForm;

React hot toast

sh
yarn add react-hot-toast

app\layout.tsx

tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { Toaster } from "react-hot-toast";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <main className="container mx-auto px-4">
          <Toaster
            position="bottom-right"
            reverseOrder={false}
          />
          {children}
        </main>
      </body>
    </html>
  );
}

Validaciones

app\todo\components\form.todo.tsx

tsx
import toast from "react-hot-toast";
...
const handleSubmit = async (data: FormData) => {
  const title = data.get("title") as string;

  if (!title || !title.trim()) {
    return toast.error("Title is required");
  }

  const res = await createTodo(title);

  if (res.error) {
    return toast.error(res.error);
  }

  toast.success(res.success as string);

  formRef.current?.reset();
};

app\todo\actions\todo.action.ts

ts
"use server";

import { prisma } from "@/libs/prismadb";
import { revalidatePath } from "next/cache";

export const createTodo = async (title: string) => {
  if (!title || !title.trim()) {
    return {
      error: "Title is required (backend)",
    };
  }

  try {
    await prisma.todo.create({ data: { title } });
    revalidatePath("/todo");
    return {
      success: "Todo created successfully",
    };
  } catch (error) {
    return {
      error: "Something went wrong",
    };
  }
};

Delete Todo

app\todo\page.tsx

tsx
import { prisma } from "@/libs/prismadb";
import FormTodo from "./components/form.todo";
import ListTodo from "./components/list.todo";

const TodoPage = async () => {
  const todos = await prisma.todo.findMany();

  return (
    <div>
      <h1 className="text-center text-3xl my-10">TodoPage</h1>
      <FormTodo />
      <ListTodo todos={todos} />
    </div>
  );
};
export default TodoPage;

app\todo\interfaces\todo.interface.ts

ts
export interface TodoInterface {
  id: string;
  title: string;
}

app\todo\components\list.todo.tsx

tsx
import { TodoInterface } from "../interfaces/todo.interface";
import ItemTodo from "./item.todo";

interface ListTodoProps {
  todos: TodoInterface[];
}

const ListTodo = ({ todos }: ListTodoProps) => {
  if (!todos.length) return <div>No todos</div>;

  return (
    <>
      {todos.map((todo) => (
        <ItemTodo
          key={todo.id}
          todo={todo}
        />
      ))}
    </>
  );
};
export default ListTodo;

useTransition

  • También puedes invocar Acciones del Servidor sin usar action o formAction.
  • Puede lograr esto mediante el enlace startTransition proporcionado por el hook useTransition, que puede ser útil si desea usar Acciones del servidor fuera de formularios, botones o entradas.
  • using-starttransitio

app\todo\components\item.todo.tsx

tsx
"use client";

import { useTransition } from "react";
import { toast } from "react-hot-toast";
import { FaSpinner, FaTrash } from "react-icons/fa";
import { removeItem } from "../actions/todo.action";
import { TodoInterface } from "../interfaces/todo.interface";

interface TodoProps {
  todo: TodoInterface;
}

const ItemTodo = ({ todo }: TodoProps) => {
  let [isPending, startTransition] = useTransition();

  const handleClickRemove = async (id: string) => {
    const res = await removeItem(id);
    if (res.error) {
      toast.error(res.error);
    } else {
      toast.success(res.success as string);
    }
  };

  return (
    <div className="flex justify-between items-center border border-gray-400 p-2 rounded mb-2">
      <span>{todo.title}</span>
      <button onClick={() => startTransition(() => handleClickRemove(todo.id))}>
        {isPending ? (
          <span className="block animate-spin">
            <FaSpinner className="transform rotate-90" />
          </span>
        ) : (
          <FaTrash />
        )}
      </button>
    </div>
  );
};
export default ItemTodo;

app\todo\actions\todo.action.ts

ts
export const removeItem = async (id: string) => {
  if (!id || !id.trim()) return { error: "Id is required (backend)" };

  try {
    await prisma.todo.delete({ where: { id } });
    revalidatePath("/todo");
    return {
      success: "Todo deleted successfully",
    };
  } catch (error) {
    return {
      error: "Something went wrong",
    };
  }
};

Zod (validaciones frontend)

sh
yarn add zod

app\todo\schema\todo.zod.schema.ts

ts
import { z } from "zod";

export const todoZodSchema = z.object({
  title: z
    .string()
    .trim()
    .min(3, { message: "Todo min 3 carácteres" })
    .max(50, { message: "Todo max 50 carácteres" })
    .nonempty({ message: "Todo no puede estar vacío" }),
});

parse

  • parse Cualquier schema de Zod se puede convertir en una función de validación utilizando el método parse. Si es correcta la validación devuelve el valor, si no, devuelve un error.
  • ZodError: Cuando se produce un error de validación, se devuelve una instancia de ZodError. Esta instancia contiene una propiedad issues que es una matriz de objetos de error. Cada objeto de error contiene información sobre el error, como el mensaje de error y la ruta de acceso al valor que no pasó la validación.

app\todo\components\form.todo.tsx

tsx
const handleSubmit = async (data: FormData) => {
  const title = data.get("title") as string;

  try {
    todoZodSchema.parse({ title });
  } catch (error) {
    if (error instanceof ZodError) {
      return error.issues.map((issue) => toast.error(issue.message));
    }
  }
  ...
};

app\layout.tsx

tsx
<Toaster
  position="bottom-center"
  reverseOrder={false}
  toastOptions={{
    className: "w-full",
  }}
/>

Zod (validaciones backend)

app\todo\actions\todo.action.ts

ts
import { ZodError } from "zod";
import { todoZodSchema } from "../schema/todo.zod.schema";

interface CreateTodoResponse {
  success: boolean;
  message: string;
}

export const createTodo = async (
  title: string
): Promise<CreateTodoResponse> => {
  try {
    todoZodSchema.parse({
      title,
    });
    await prisma.todo.create({
      data: {
        title: title.trim(),
      },
    });
    revalidatePath("/todo");
    return {
      success: true,
      message: "Todo created (backend)",
    };
  } catch (error) {
    if (error instanceof ZodError) {
      return {
        success: false,
        message: error.issues[0].message,
      };
    }

    return {
      success: false,
      message: "error de servidor (backend)",
    };
  }
};

app\todo\components\form.todo.tsx

tsx
const handleSubmit = async (data: FormData) => {
  const title = data.get("title") as string;

  try {
    // se comentó para forzar validaciones en el backend
    // todoZodSchema.parse({ title });
    const res = await createTodo(title);
    if (!res.success) {
      return toast.error(res.message);
    }
    toast.success(res.message);
  } catch (error) {
    console.log(error);
    if (error instanceof ZodError) {
      return error.issues.map((issue) => toast.error(issue.message));
    }
  } finally {
    formRef.current?.reset();
  }
};

Clerk (autenticación)

sh
yarn add @clerk/nextjs

.env

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=

app/layout.tsx (ejemplo, hay que adaptar según el proyecto)

tsx
import { ClerkProvider } from "@clerk/nextjs";

export const metadata = {
  title: "Next.js 13 with Clerk",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body>{children}</body>
      </html>
    </ClerkProvider>
  );
}

Middleware

  • Crear archivo middleware.ts en la raíz del proyecto (en caso de estar trabajando con la carpeta src, tiene que ir en la raíz de src).
  • Con este middleware, toda su aplicación está protegida. Si intenta acceder a él, el middleware lo redirigirá a su página de registro.
  • Si desea hacer públicas otras rutas, consulte la página de referencia de authMiddleware.
ts
import { authMiddleware } from "@clerk/nextjs";

// This example protects all routes including api/trpc routes
// Please edit this to allow other routes to be public as needed.
// See https://clerk.com/docs/references/nextjs/auth-middleware for more information about configuring your middleware
export default authMiddleware({});

export const config = {
  matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"],
};

Routes Login y Register

app(auth)\login\page.tsx

tsx
import { SignIn } from "@clerk/nextjs";

export default function Page() {
  return <SignIn />;
}

app(auth)\register\page.tsx

tsx
import { SignUp } from "@clerk/nextjs";

export default function Page() {
  return <SignUp />;
}

app(auth)\layout.tsx

tsx
interface AuthLayoutProps {
  children: React.ReactNode;
}

const AuthLayout = ({ children }: AuthLayoutProps) => {
  return <div className="min-h-screen grid place-items-center">{children}</div>;
};
export default AuthLayout;

.env

NEXT_PUBLIC_CLERK_SIGN_IN_URL=/login
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/register
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/

UserButton

tsx
import { UserButton } from "@clerk/nextjs";

export default function Home() {
  return (
    <div>
      <UserButton afterSignOutUrl="/" />
    </div>
  );
}

User

prisma\schema.prisma

ts
model Todo {
  id     String @id @default(auto()) @map("_id") @db.ObjectId
  title  String
  userId String
}
sh
npx prisma db push

app\todo\actions\todo.action.ts

ts
"use server";

import { prisma } from "@/libs/prismadb";
import { revalidatePath } from "next/cache";

import { ZodError } from "zod";
import { todoZodSchema } from "../schema/todo.zod.schema";

import { auth } from "@clerk/nextjs";

interface CreateTodoResponse {
  success: boolean;
  message: string;
}

export const createTodo = async (
  title: string
): Promise<CreateTodoResponse> => {
  const { userId }: { userId: string | null } = auth();

  if (!userId)
    return {
      success: false,
      message: "No user id (backend)",
    };

  try {
    todoZodSchema.parse({
      title,
    });
    await prisma.todo.create({
      data: {
        title: title.trim(),
        userId: userId,
      },
    });
    revalidatePath("/todo");
    return {
      success: true,
      message: "Todo created (backend)",
    };
  } catch (error) {
    if (error instanceof ZodError) {
      return {
        success: false,
        message: error.issues[0].message,
      };
    }

    return {
      success: false,
      message: "error de servidor (backend)",
    };
  }
};

app\page.tsx

tsx
import FormTodo from "@/app/todo/components/form.todo";
import ListTodo from "@/app/todo/components/list.todo";
import { prisma } from "@/libs/prismadb";
import { UserButton } from "@clerk/nextjs";

import { currentUser } from "@clerk/nextjs";
import type { User } from "@clerk/nextjs/api";

const TodoPage = async () => {
  const user: User | null = await currentUser();

  if (!user) {
    return <div className="text-center text-2xl">User not found</div>;
  }

  const todos = await prisma.todo.findMany({
    where: {
      userId: user.id,
    },
  });

  return (
    <div className="space-y-5">
      <h1 className="text-center text-3xl my-10">Todos: {user.firstName}</h1>
      <UserButton afterSignOutUrl="/" />
      <FormTodo />
      <ListTodo todos={todos} />
    </div>
  );
};

export default TodoPage;