Fundamentos do Next.js 15
Criando e Gerenciando Dados (CRUD) com Server Actions
Aprenda sobre criando e gerenciando dados (crud) com server actions
Criando e Gerenciando Dados (CRUD) com Server Actions
Bem-vindos à aula prática do Módulo 5! 👋 Nesta etapa crucial do nosso projeto final, vamos mergulhar no coração da interatividade de uma aplicação full stack: a criação, leitura, atualização e exclusão (CRUD) de dados. E a ferramenta que usaremos para fazer isso de forma elegante e eficiente no Next.js 15 são os Server Actions.
🚀 1. Introdução: O Poder dos Server Actions
Até agora, você provavelmente já está familiarizado com a leitura de dados utilizando fetch em Server Components. Mas e quando precisamos modificar dados? Inserir um novo item, atualizar um registro existente ou deletar algo? Tradicionalmente, isso envolveria a criação de rotas de API separadas (/api/minha-acao), o que adiciona uma camada de complexidade e boilerplate.
É aí que os Server Actions brilham! ✨
Server Actions são funções assíncronas que executam diretamente no servidor. Eles permitem que você defina mutações de dados e outras operações de backend dentro dos seus componentes React (ou em arquivos separados), sem a necessidade de criar rotas de API REST/GraphQL explícitas. Isso simplifica drasticamente o desenvolvimento de aplicações full stack, aproximando o código do frontend e do backend de uma maneira segura e performática.
Por que Server Actions?
- Simplificação: Reduz a necessidade de rotas de API, diminuindo o boilerplate.
- Performance: Menos requisições de rede, pois a função pode ser chamada diretamente.
- Segurança: Executam no servidor, protegendo segredos e lógica de negócios.
- Integração: Funcionam perfeitamente com formulários HTML e hooks como
useTransitioneuseOptimisticdo React.
Nesta aula, vamos construir funcionalidades CRUD usando Server Actions para interagir com um "banco de dados" simulado (ou, para simplificar, um array em memória que simula um armazenamento persistente para fins didáticos, ou você pode conectar a um banco de dados real se já tiver configurado).
💡 2. Explicação Detalhada com Exemplos
Vamos entender como Server Actions funcionam na prática.
2.1. Definindo um Server Action
Um Server Action é simplesmente uma função assíncrona marcada com a diretiva 'use server'. Essa diretiva informa ao compilador do Next.js que esta função deve ser executada exclusivamente no servidor.
Você pode definir um Server Action de duas maneiras principais:
- Dentro de um Server Component:
// app/dashboard/page.tsx import { revalidatePath } from 'next/cache'; export default function DashboardPage() { async function createPost(formData: FormData) { 'use server'; // <--- A diretiva 'use server' const title = formData.get('title') as string; const content = formData.get('content') as string; // Lógica para salvar no banco de dados console.log('Novo post criado:', { title, content }); // Simulando um delay de rede await new Promise(resolve => setTimeout(resolve, 1000)); // Revalidar o cache para que a lista de posts seja atualizada revalidatePath('/dashboard'); } return ( <div> <h1>Criar Novo Post</h1> <form action={createPost}> <input type="text" name="title" placeholder="Título do Post" required /> <textarea name="content" placeholder="Conteúdo do Post" required /> <button type="submit">Criar Post</button> </form> </div> ); } - Em um arquivo separado (recomendado para organização):
// app/actions.ts 'use server'; // <--- A diretiva 'use server' no topo do arquivo aplica-se a todas as exportações import { revalidatePath } from 'next/cache'; interface Post { id: string; title: string; content: string; } // Simulando um banco de dados em memória const posts: Post[] = [ { id: '1', title: 'Meu Primeiro Post', content: 'Conteúdo inicial.' }, ]; let nextId = 2; export async function createPost(formData: FormData) { const title = formData.get('title') as string; const content = formData.get('content') as string; if (!title || !content) { throw new Error('Título e conteúdo são obrigatórios.'); } const newPost: Post = { id: String(nextId++), title, content }; posts.push(newPost); console.log('Novo post criado:', newPost); await new Promise(resolve => setTimeout(resolve, 500)); // Simula delay de rede revalidatePath('/dashboard'); // Revalida a página para mostrar o novo post return newPost; // Opcional: retornar dados } export async function getPosts(): Promise<Post[]> { // Esta função não é um Server Action para mutação, mas pode ser usada para buscar dados // Se for chamada de um Client Component, ela se tornará uma API call. // Se for chamada de um Server Component, ela é apenas uma função do servidor. await new Promise(resolve => setTimeout(resolve, 300)); return posts; } export async function deletePost(id: string) { const initialLength = posts.length; const index = posts.findIndex(post => post.id === id); if (index > -1) { posts.splice(index, 1); } console.log(`Post ${id} deletado.`); await new Promise(resolve => setTimeout(resolve, 500)); revalidatePath('/dashboard'); return initialLength !== posts.length; // Retorna true se deletou } export async function updatePost(id: string, formData: FormData) { const title = formData.get('title') as string; const content = formData.get('content') as string; if (!title || !content) { throw new Error('Título e conteúdo são obrigatórios.'); } const postToUpdate = posts.find(post => post.id === id); if (postToUpdate) { postToUpdate.title = title; postToUpdate.content = content; } console.log(`Post ${id} atualizado.`); await new Promise(resolve => setTimeout(resolve, 500)); revalidatePath('/dashboard'); return postToUpdate; }
2.2. Invocando Server Actions
A maneira mais comum e poderosa de invocar um Server Action é através do atributo action de um elemento <form>. Quando o formulário é submetido, o Next.js intercepta a submissão e chama o Server Action.
// app/dashboard/page.tsx
import { createPost, getPosts, deletePost } from '@/app/actions'; // Importe os Server Actions
import PostCard from '@/components/PostCard'; // Componente de exemplo para exibir post
export default async function DashboardPage() {
const posts = await getPosts(); // Assume que getPosts é um Server Function
return (
<div className="container mx-auto p-4">
<h1 className="text-3xl font-bold mb-6">Gerenciar Posts</h1>
<section className="mb-8 p-6 bg-gray-100 rounded-lg shadow-md">
<h2 className="text-2xl font-semibold mb-4">Criar Novo Post</h2>
<form action={createPost} className="space-y-4">
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700">Título:</label>
<input
type="text"
id="title"
name="title"
placeholder="Título do Post"
required
className="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label htmlFor="content" className="block text-sm font-medium text-gray-700">Conteúdo:</label>
<textarea
id="content"
name="content"
placeholder="Conteúdo do Post"
required
rows={4}
className="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
></textarea>
</div>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white font-semibold rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Criar Post
</button>
</form>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">Posts Existentes</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
</section>
</div>
);
}
// components/PostCard.tsx (Exemplo de Client Component para exibir e deletar)
'use client';
import { deletePost } from '@/app/actions';
import { useTransition } from 'react';
interface PostCardProps {
post: {
id: string;
title: string;
content: string;
};
}
export default function PostCard({ post }: PostCardProps) {
const [isPending, startTransition] = useTransition();
const handleDelete = async () => {
startTransition(async () => {
await deletePost(post.id);
});
};
return (
<div className="bg-white p-4 rounded-lg shadow-md border border-gray-200">
<h3 className="text-xl font-bold mb-2">{post.title}</h3>
<p className="text-gray-700 mb-4">{post.content}</p>
<button
onClick={handleDelete}
disabled={isPending}
className="px-3 py-1 bg-red-500 text-white rounded-md hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isPending ? 'Deletando...' : 'Deletar'}
</button>
{/* Aqui você poderia adicionar um botão de Editar que abre um modal ou navega para uma página de edição */}
</div>
);
}Observações:
FormData: Quando um Server Action é invocado viaformaction, o primeiro argumento que ele recebe é um objetoFormData, que contém todos os campos do formulário.revalidatePath: Após uma mutação, é crucial chamarrevalidatePath('/seu-caminho')ourevalidateTag('sua-tag')para informar ao Next.js que os dados em cache para aquela rota/tag estão desatualizados e precisam ser buscados novamente na próxima renderização. Isso garante que o usuário veja as alterações.useTransition(para Client Components): Se você precisar chamar um Server Action de um Client Component (por exemplo, um botão de "Deletar" que não está dentro de um<form>), você pode usar o hookuseTransitiondo React. Ele permite que você mantenha a UI responsiva enquanto a ação assíncrona está em andamento, sem bloquear a interface.isPendingindica se a transição está ativa.
2.3. Manipulando Erros
É vital lidar com erros que podem ocorrer durante a execução de um Server Action. Use blocos try...catch dentro do seu Server Action.
// app/actions.ts (atualizado)
'use server';
import { revalidatePath } from 'next/cache';
// ... (definição de Post, posts, nextId)
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
try {
if (!title || !content) {
throw new Error('Título e conteúdo são obrigatórios.');
}
const newPost = { id: String(nextId++), title, content };
posts.push(newPost);
console.log('Novo post criado:', newPost);
await new Promise(resolve => setTimeout(resolve, 500));
revalidatePath('/dashboard');
return { success: true, data: newPost }; // Retorna um objeto com status
} catch (error: any) {
console.error('Erro ao criar post:', error.message);
return { success: false, error: error.message }; // Retorna o erro
}
}No Client Component, você pode verificar o resultado:
// components/PostForm.tsx (Exemplo de Client Component para formulário com feedback)
'use client';
import { createPost } from '@/app/actions';
import { useRef, useState } from 'react';
export default function PostForm() {
const formRef = useRef<HTMLFormElement>(null);
const [message, setMessage] = useState('');
const [isError, setIsError] = useState(false);
const handleSubmit = async (formData: FormData) => {
setMessage('');
setIsError(false);
const result = await createPost(formData);
if (result.success) {
setMessage('Post criado com sucesso!');
formRef.current?.reset(); // Limpa o formulário
} else {
setIsError(true);
setMessage(`Erro: ${result.error}`);
}
};
return (
<form ref={formRef} action={handleSubmit} className="space-y-4">
{/* ... campos do formulário ... */}
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white font-semibold rounded-md hover:bg-blue-700"
>
Criar Post
</button>
{message && (
<p className={`mt-2 text-sm ${isError ? 'text-red-600' : 'text-green-600'}`}>
{message}
</p>
)}
</form>
);
}2.4. Validação de Entrada
Sempre valide os dados de entrada no servidor. Isso é crucial para a segurança e integridade dos seus dados. Você pode usar bibliotecas como zod para isso.
// app/actions.ts (com validação Zod)
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod'; // Certifique-se de ter 'zod' instalado: npm install zod
// ... (definição de Post, posts, nextId)
const postSchema = z.object({
title: z.string().min(3, { message: 'O título deve ter pelo menos 3 caracteres.' }).max(100),
content: z.string().min(10, { message: 'O conteúdo deve ter pelo menos 10 caracteres.' }),
});
export async function createPost(formData: FormData) {
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
};
try {
const validatedData = postSchema.parse(rawData); // Valida os dados
const newPost = { id: String(nextId++), ...validatedData };
posts.push(newPost);
console.log('Novo post criado:', newPost);
await new Promise(resolve => setTimeout(resolve, 500));
revalidatePath('/dashboard');
return { success: true, data: newPost };
} catch (error: any) {
if (error instanceof z.ZodError) {
// Erro de validação do Zod
const issues = error.issues.map(issue => issue.message).join(', ');
return { success: false, error: `Erro de validação: ${issues}` };
}
console.error('Erro ao criar post:', error.message);
return { success: false, error: error.message || 'Ocorreu um erro desconhecido.' };
}
}🛠️ 3. Exercícios Práticos: Construindo um Gerenciador de Tarefas
Vamos aplicar o que aprendemos construindo um pequeno gerenciador de tarefas (Todo List) que utiliza Server Actions para todas as operações CRUD.
Pré-requisitos:
- Um projeto Next.js 15 configurado (com App Router).
npm install zodpara validação.- Opcional:
npm install tailwindcss postcss autoprefixere configure o Tailwind CSS para estilização rápida.
Tarefas:
-
Estrutura Inicial:
- Crie um arquivo
app/actions.tspara seus Server Actions. - Crie uma página
app/todos/page.tsxpara exibir e gerenciar as tarefas. - (Opcional) Crie um componente
components/TodoItem.tsxpara cada item da lista.
- Crie um arquivo
-
Definir o "Banco de Dados" (em memória):
- No
app/actions.ts, crie um array globaltodose umnextIdpara simular um armazenamento. - Defina uma interface
Todo(ex:id: string; text: string; completed: boolean;).
// app/actions.ts 'use server'; import { revalidatePath } from 'next/cache'; import { z } from 'zod'; interface Todo { id: string; text: string; completed: boolean; } const todos: Todo[] = [ { id: '1', text: 'Aprender Server Actions', completed: false }, { id: '2', text: 'Construir um projeto Next.js', completed: true }, ]; let nextId = 3; const todoSchema = z.object({ text: z.string().min(1, { message: 'A tarefa não pode ser vazia.' }).max(200), }); // ... (Seus Server Actions virão aqui) - No
-
READ (Leitura de Tarefas):
- Crie uma função
getTodos()emapp/actions.tsque retorna o arraytodos. - Em
app/todos/page.tsx, chamegetTodos()dentro do Server Component para exibir a lista de tarefas.
// app/actions.ts // ... export async function getTodos(): Promise<Todo[]> { await new Promise(resolve => setTimeout(resolve, 300)); // Simula delay return todos; }// app/todos/page.tsx import { getTodos } from '@/app/actions'; import AddTodoForm from '@/components/AddTodoForm'; // Criaremos este import TodoItem from '@/components/TodoItem'; // Criaremos este export default async function TodosPage() { const todos = await getTodos(); return ( <div className="container mx-auto p-4 max-w-xl"> <h1 className="text-4xl font-bold text-center mb-8">Meu Gerenciador de Tarefas</h1> <AddTodoForm /> {/* Formulário para adicionar */} <section className="mt-8"> <h2 className="text-2xl font-semibold mb-4">Minhas Tarefas</h2> {todos.length === 0 ? ( <p className="text-gray-600 text-center">Nenhuma tarefa ainda. Adicione uma!</p> ) : ( <ul className="space-y-4"> {todos.map((todo) => ( <TodoItem key={todo.id} todo={todo} /> ))} </ul> )} </section> </div> ); } - Crie uma função
-
CREATE (Criação de Tarefas):
- Crie um Server Action
addTodo(formData: FormData)emapp/actions.ts.- Use
todoSchema.parsepara validar otext. - Adicione a nova tarefa ao array
todos. - Chame
revalidatePath('/todos'). - Retorne um objeto
{ success: true, data: newTodo }ou{ success: false, error: message }.
- Use
- Crie um Client Component
components/AddTodoForm.tsx.- Ele deve conter um
<form>com um<input type="text" name="text">e um<button type="submit">. - Use o atributo
actiondo formulário para chamar oaddTodo. - Implemente feedback visual (mensagem de sucesso/erro) e limpe o formulário após o sucesso.
- Ele deve conter um
// app/actions.ts (adicionar) // ... export async function addTodo(formData: FormData) { const rawText = formData.get('text'); try { const { text } = todoSchema.parse({ text: rawText }); const newTodo: Todo = { id: String(nextId++), text, completed: false }; todos.push(newTodo); await new Promise(resolve => setTimeout(resolve, 500)); revalidatePath('/todos'); return { success: true, data: newTodo }; } catch (error: any) { if (error instanceof z.ZodError) { return { success: false, error: error.issues[0].message }; } return { success: false, error: 'Falha ao adicionar tarefa.' }; } }// components/AddTodoForm.tsx 'use client'; import { addTodo } from '@/app/actions'; import { useRef, useState } from 'react'; export default function AddTodoForm() { const formRef = useRef<HTMLFormElement>(null); const [message, setMessage] = useState(''); const [isError, setIsError] = useState(false); const handleSubmit = async (formData: FormData) => { setMessage(''); setIsError(false); const result = await addTodo(formData); if (result.success) { setMessage('Tarefa adicionada com sucesso!'); formRef.current?.reset(); } else { setIsError(true); setMessage(`Erro: ${result.error}`); } }; return ( <div className="p-6 bg-white rounded-lg shadow-md mb-8"> <h2 className="text-2xl font-semibold mb-4">Adicionar Nova Tarefa</h2> <form ref={formRef} action={handleSubmit} className="flex flex-col gap-4"> <input type="text" name="text" placeholder="O que precisa ser feito?" required className="flex-grow p-3 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500" /> <button type="submit" className="px-5 py-3 bg-blue-600 text-white font-semibold rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" > Adicionar Tarefa </button> {message && ( <p className={`mt-2 text-sm ${isError ? 'text-red-600' : 'text-green-600'}`}> {message} </p> )} </form> </div> ); } - Crie um Server Action
-
UPDATE (Atualização de Tarefas - Marcar como Concluída):
- Crie um Server Action
toggleTodo(id: string)emapp/actions.ts.- Encontre a tarefa pelo
ide inverta seu statuscompleted. - Chame
revalidatePath('/todos').
- Encontre a tarefa pelo
- Crie um Client Component
components/TodoItem.tsx.- Ele deve exibir o texto da tarefa e um checkbox.
- Use
useTransitione umonClickno checkbox para chamartoggleTodo. - Exiba o texto da tarefa riscado se
completedfortrue.
// app/actions.ts (adicionar) // ... export async function toggleTodo(id: string) { try { const todo = todos.find(t => t.id === id); if (!todo) { throw new Error('Tarefa não encontrada.'); } todo.completed = !todo.completed; await new Promise(resolve => setTimeout(resolve, 300)); revalidatePath('/todos'); return { success: true }; } catch (error: any) { console.error('Erro ao alternar tarefa:', error.message); return { success: false, error: error.message }; } }// components/TodoItem.tsx 'use client'; import { toggleTodo } from '@/app/actions'; import { useTransition } from 'react'; interface TodoItemProps { todo: { id: string; text: string; completed: boolean; }; } export default function TodoItem({ todo }: TodoItemProps) { const [isPending, startTransition] = useTransition(); const handleToggle = async () => { startTransition(async () => { await toggleTodo(todo.id); }); }; return ( <li className="flex items-center bg-gray-50 p-4 rounded-md shadow-sm border border-gray-200"> <input type="checkbox" checked={todo.completed} onChange={handleToggle} disabled={isPending} className="h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500" /> <span className={`ml-3 text-lg flex-grow ${ todo.completed ? 'line-through text-gray-500' : 'text-gray-900' }`} > {todo.text} </span> {/* Adicione o botão de deletar aqui no próximo passo */} </li> ); } - Crie um Server Action
-
DELETE (Exclusão de Tarefas):
- Crie um Server Action
deleteTodo(id: string)emapp/actions.ts.- Remova a tarefa do array
todospeloid. - Chame
revalidatePath('/todos').
- Remova a tarefa do array
- Em
components/TodoItem.tsx, adicione um botão "Deletar".- Use
useTransitione umonClickpara chamardeleteTodo.
- Use
// app/actions.ts (adicionar) // ... export async function deleteTodo(id: string) { try { const initialLength = todos.length; const index = todos.findIndex(t => t.id === id); if (index > -1) { todos.splice(index, 1); } else { throw new Error('Tarefa não encontrada para exclusão.'); } await new Promise(resolve => setTimeout(resolve, 300)); revalidatePath('/todos'); return { success: initialLength !== todos.length }; } catch (error: any) { console.error('Erro ao deletar tarefa:', error.message); return { success: false, error: error.message }; } }// components/TodoItem.tsx (atualizar) 'use client'; import { toggleTodo, deleteTodo } from '@/app/actions'; // Importe deleteTodo import { useTransition } from 'react'; // ... (interface TodoItemProps) export default function TodoItem({ todo }: TodoItemProps) { const [isPendingToggle, startToggleTransition] = useTransition(); const [isPendingDelete, startDeleteTransition] = useTransition(); const handleToggle = async () => { startToggleTransition(async () => { await toggleTodo(todo.id); }); }; const handleDelete = async () => { if (confirm('Tem certeza que deseja deletar esta tarefa?')) { startDeleteTransition(async () => { await deleteTodo(todo.id); }); } }; return ( <li className="flex items-center bg-gray-50 p-4 rounded-md shadow-sm border border-gray-200"> <input type="checkbox" checked={todo.completed} onChange={handleToggle} disabled={isPendingToggle} className="h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500" /> <span className={`ml-3 text-lg flex-grow ${ todo.completed ? 'line-through text-gray-500' : 'text-gray-900' }`} > {todo.text} </span> <button onClick={handleDelete} disabled={isPendingDelete} className="ml-4 px-3 py-1 bg-red-500 text-white rounded-md hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm" > {isPendingDelete ? 'Deletando...' : 'Deletar'} </button> </li> ); } - Crie um Server Action
Após completar esses passos, você terá uma aplicação Next.js full stack funcional, utilizando Server Actions para gerenciar seus dados de forma eficiente! 🎉
📝 4. Resumo e Próximos Passos
Parabéns! Você dominou a arte de criar e gerenciar dados usando Server Actions no Next.js 15.
Pontos Chave Desta Aula:
- Server Actions permitem que você execute código diretamente no servidor, eliminando a necessidade de rotas de API para mutações.
- A diretiva
'use server'é essencial para definir um Server Action. - Formulários HTML com o atributo
actionsão a maneira mais comum de invocar Server Actions, passando automaticamente oFormData. revalidatePath(ourevalidateTag) é crucial para atualizar o cache e garantir que as mudanças de dados sejam refletidas na UI.useTransitionpermite que você mantenha a UI responsiva ao chamar Server Actions de Client Components.- Validação de entrada (com Zod, por exemplo) e tratamento de erros são fundamentais para a robustez da sua aplicação.
Próximos Passos:
- Otimização de UI: Explore o hook
useOptimisticdo React para criar experiências de usuário ainda mais fluidas, onde as mudanças são refletidas instantaneamente na UI antes mesmo da resposta do servidor. - Conectando a um Banco de Dados Real: Substitua o array em memória por uma conexão a um banco de dados real (PostgreSQL, MongoDB, etc.) usando um ORM como Prisma ou Drizzle.
- Autenticação e Autorização: Integre Server Actions com soluções de autenticação para proteger suas mutações de dados, garantindo que apenas usuários autorizados possam realizar certas operações.
- Integração com Caching Avançado: Aprofunde-se no sistema de cache do Next.js para otimizar ainda mais o desempenho da sua aplicação.
Continue praticando e explorando as possibilidades dos Server Actions. Eles são uma peça fundamental para construir aplicações full stack modernas e eficientes com Next.js!