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:
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:
"use server";
export async function addItem(data) {
const cartId = cookies().get("cartId")?.value;
await saveToDb({ cartId, data });
}
"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
npx create-next-app@latest
yarn create next-app
Habilitar Server Actions
next.config.js
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
npm install prisma -D
yarn add prisma -DE
Prisma Schema
npx prisma init
Este comando hace dos cosas:
- 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.
- 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
// 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
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 dePrismaClient
y la asigna aprisma
. Esto garantiza que solo haya una instancia de PrismaClient en toda la aplicación.
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
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
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
"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
"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
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
"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
yarn add react-icons
useFormStatus
- Se puede usar dentro de Acciones de formulario y proporciona la propiedad pending.
- experimental-useformstatus
app\todo\components\button-form.todo.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
yarn add react-hot-toast
app\layout.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
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
"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
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
export interface TodoInterface {
id: string;
title: string;
}
app\todo\components\list.todo.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
"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
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)
yarn add zod
app\todo\schema\todo.zod.schema.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
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
<Toaster
position="bottom-center"
reverseOrder={false}
toastOptions={{
className: "w-full",
}}
/>
Zod (validaciones backend)
app\todo\actions\todo.action.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
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)
yarn add @clerk/nextjs
.env
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
app/layout.tsx (ejemplo, hay que adaptar según el proyecto)
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.
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
import { SignIn } from "@clerk/nextjs";
export default function Page() {
return <SignIn />;
}
app(auth)\register\page.tsx
import { SignUp } from "@clerk/nextjs";
export default function Page() {
return <SignUp />;
}
app(auth)\layout.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
import { UserButton } from "@clerk/nextjs";
export default function Home() {
return (
<div>
<UserButton afterSignOutUrl="/" />
</div>
);
}
User
prisma\schema.prisma
model Todo {
id String @id @default(auto()) @map("_id") @db.ObjectId
title String
userId String
}
npx prisma db push
app\todo\actions\todo.action.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
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;