Real-World Server Actions: Building a Todo App
This article ties together everything from the series: Server Actions, validation, revalidation, optimistic updates, error handling, and security. We'll build a real todo application with create, read, update, and delete (CRUD) operations, progressive enhancement, and offline-first UX using optimistic updates.
The app demonstrates professional patterns: Zod schemas for validation, role-based authorization, rate limiting on critical actions, comprehensive error handling, and clean separation of concerns. By the end, you'll have a template for building more complex fullstack applications.
Architecture Overview
The app has three layers:
- Database layer (
lib/db.ts): Simulated database with in-memory storage (replace with real database) - Server Actions (
app/actions.ts): All mutations and data fetches - Client Components (
app/components/): UI with optimistic updates and validation
Database Simulation
// lib/db.ts
import { z } from 'zod';
export interface Todo {
id: string;
title: string;
completed: boolean;
userId: string;
createdAt: Date;
updatedAt: Date;
}
// Simulated database
const todos: Todo[] = [];
export const db = {
todos: {
create: async (data: Omit<Todo, 'id' | 'createdAt' | 'updatedAt'>) => {
const todo: Todo = {
...data,
id: crypto.randomUUID(),
createdAt: new Date(),
updatedAt: new Date(),
};
todos.push(todo);
return todo;
},
findByUserId: async (userId: string) => {
return todos.filter((t) => t.userId === userId);
},
findById: async (id: string) => {
return todos.find((t) => t.id === id);
},
update: async (id: string, data: Partial<Omit<Todo, 'id' | 'createdAt'>>) => {
const todo = todos.find((t) => t.id === id);
if (!todo) return null;
Object.assign(todo, data, { updatedAt: new Date() });
return todo;
},
delete: async (id: string) => {
const index = todos.findIndex((t) => t.id === id);
if (index === -1) return false;
todos.splice(index, 1);
return true;
},
},
};
Server Actions with Validation and Security
// app/actions.ts
'use server';
import { revalidateTag } from 'next/cache';
import { z } from 'zod';
import { db, type Todo } from '@/lib/db';
// Mock current user
async function getCurrentUser() {
return { id: 'user-123', name: 'Alice' };
}
// Schemas
const createTodoSchema = z.object({
title: z.string().min(1, 'Title is required').max(200, 'Title is too long'),
});
const updateTodoSchema = z.object({
title: z.string().min(1).max(200).optional(),
completed: z.boolean().optional(),
});
// Create
export async function createTodo(formData: FormData) {
const user = await getCurrentUser();
const title = formData.get('title') as string;
const result = createTodoSchema.safeParse({ title });
if (!result.success) {
throw new Error(result.error.errors[0].message);
}
const todo = await db.todos.create({
title: result.data.title,
completed: false,
userId: user.id,
});
revalidateTag(`todos-${user.id}`);
return { success: true, todo };
}
// Read (fetch all for user)
export async function getTodos() {
const user = await getCurrentUser();
const todos = await db.todos.findByUserId(user.id);
return todos;
}
// Update
export async function updateTodo(prevState: any, formData: FormData) {
const user = await getCurrentUser();
const id = formData.get('id') as string;
const title = formData.get('title') as string | null;
const completed = formData.get('completed') as string | null;
// Find and verify ownership
const todo = await db.todos.findById(id);
if (!todo || todo.userId !== user.id) {
return { success: false, error: 'Todo not found or unauthorized', todo: null };
}
// Validate input
const updateData: any = {};
if (title !== null) updateData.title = title;
if (completed !== null) updateData.completed = completed === 'true';
const result = updateTodoSchema.safeParse(updateData);
if (!result.success) {
return { success: false, error: result.error.errors[0].message, todo: null };
}
// Update
const updated = await db.todos.update(id, result.data);
revalidateTag(`todos-${user.id}`);
return { success: true, error: null, todo: updated };
}
// Delete
export async function deleteTodo(id: string) {
const user = await getCurrentUser();
const todo = await db.todos.findById(id);
if (!todo || todo.userId !== user.id) {
throw new Error('Todo not found or unauthorized');
}
await db.todos.delete(id);
revalidateTag(`todos-${user.id}`);
return { success: true };
}
Todo List Component with Optimistic Updates
// app/components/TodoList.tsx
'use client';
import { useOptimistic } from 'react';
import { deleteTodo, updateTodo } from '@/app/actions';
import type { Todo } from '@/lib/db';
export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [optimisticTodos, updateOptimisticTodos] = useOptimistic(
initialTodos,
(state: Todo[], action: { type: string; todo?: Todo; id?: string }) => {
if (action.type === 'update' && action.todo) {
return state.map((t) => (t.id === action.todo.id ? action.todo : t));
}
if (action.type === 'delete' && action.id) {
return state.filter((t) => t.id !== action.id);
}
return state;
}
);
async function handleToggle(todo: Todo) {
// Optimistically update
updateOptimisticTodos({
type: 'update',
todo: { ...todo, completed: !todo.completed },
});
try {
const formData = new FormData();
formData.set('id', todo.id);
formData.set('completed', String(!todo.completed));
const result = await updateTodo({}, formData);
if (!result.success) {
throw new Error(result.error);
}
} catch (error) {
console.error('Toggle failed:', error);
// useOptimistic reverts on error
}
}
async function handleDelete(id: string) {
// Optimistically remove
updateOptimisticTodos({ type: 'delete', id });
try {
await deleteTodo(id);
} catch (error) {
console.error('Delete failed:', error);
// useOptimistic reverts on error
}
}
return (
<ul style={{ listStyle: 'none', padding: 0 }}>
{optimisticTodos.map((todo) => (
<li
key={todo.id}
style={{
padding: '12px',
borderBottom: '1px solid #ddd',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
textDecoration: todo.completed ? 'line-through' : 'none',
opacity: todo.completed ? 0.5 : 1,
}}
>
<span>{todo.title}</span>
<div>
<button
onClick={() => handleToggle(todo)}
style={{ marginRight: '8px', padding: '4px 8px' }}
>
{todo.completed ? 'Undo' : 'Done'}
</button>
<button
onClick={() => handleDelete(todo.id)}
style={{ padding: '4px 8px', color: 'red' }}
>
Delete
</button>
</div>
</li>
))}
</ul>
);
}
Add Todo Form
// app/components/AddTodoForm.tsx
'use client';
import { useActionState } from 'react';
import { createTodo } from '@/app/actions';
export function AddTodoForm({ onAdd }: { onAdd?: () => void }) {
const [state, formAction, isPending] = useActionState(createTodo, {
success: false,
todo: null,
});
return (
<form action={formAction} style={{ marginBottom: '20px' }}>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
name="title"
placeholder="Add a new todo..."
required
disabled={isPending}
style={{ flex: 1, padding: '8px', fontSize: '16px' }}
/>
<button
type="submit"
disabled={isPending}
style={{ padding: '8px 16px', cursor: isPending ? 'wait' : 'pointer' }}
>
{isPending ? 'Adding...' : 'Add'}
</button>
</div>
{state?.error && <p style={{ color: 'red', marginTop: '8px' }}>{state.error}</p>}
</form>
);
}
Main Page Component
// app/page.tsx
import { getTodos } from '@/app/actions';
import { AddTodoForm } from '@/app/components/AddTodoForm';
import { TodoList } from '@/app/components/TodoList';
export default async function Home() {
const todos = await getTodos();
return (
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
<h1>My Todos</h1>
<AddTodoForm />
{todos.length === 0 ? (
<p style={{ textAlign: 'center', color: '#999' }}>
No todos yet. Add one to get started!
</p>
) : (
<TodoList initialTodos={todos} />
)}
</main>
);
}
Key Patterns Demonstrated
Progressive Enhancement: The form works without JavaScript. With JavaScript, useActionState shows loading state and errors inline.
Optimistic Updates: Todos toggle and delete instantly with visual feedback, while the Server Action runs in the background.
Validation: Zod schemas validate on the server; errors are returned and displayed.
Authorization: Every mutation checks that the todo belongs to the current user.
Revalidation: After each mutation, revalidateTag() invalidates the cache, so the next render fetches fresh data.
Error Handling: Both thrown errors and returned error states are handled gracefully.
Extending the App
Add filtering:
export async function getTodosByStatus(status: 'all' | 'completed' | 'pending') {
const user = await getCurrentUser();
const todos = await db.todos.findByUserId(user.id);
return todos.filter((t) => {
if (status === 'completed') return t.completed;
if (status === 'pending') return !t.completed;
return true;
});
}
Add due dates:
interface Todo {
// ... existing fields
dueDate?: Date;
}
const createTodoSchema = z.object({
title: z.string().min(1).max(200),
dueDate: z.string().datetime().optional(),
});
Add sharing and collaboration:
interface Todo {
// ... existing fields
sharedWith: string[]; // Array of user IDs
}
export async function shareTodo(todoId: string, userId: string) {
// Validate ownership, add userId to sharedWith
}
Key Takeaways
- This todo app demonstrates all Server Actions patterns in a cohesive project.
- Use a consistent schema validation approach (Zod) across all actions.
- Always check authorization before mutating; never trust the client.
- Combine optimistic updates for perceived performance with proper error handling for resilience.
- Call
revalidateTag()after mutations to keep the UI in sync with the server.
Frequently Asked Questions
How do I persist todos between page reloads?
Replace the in-memory todos array with a real database (PostgreSQL, MongoDB). Use a database client like Prisma or Drizzle. Update db.todos.create(), db.todos.update(), and db.todos.delete() to call the database API instead of manipulating an array.
How do I add authentication to the app?
Use a library like NextAuth.js or Clerk. Replace getCurrentUser() with a call to your auth provider. Verify the user's session on every action before allowing mutations.
Can I add real-time collaboration?
Yes, use WebSockets (Socket.io) to broadcast mutations to all connected clients. When a user creates a todo, emit a socket event, and other users' components receive the update. Combine with optimistic updates for instant local feedback.
How do I handle offline todos?
Use a local IndexedDB or localStorage to store pending mutations. When the network is back, sync with the server using a background task. This is complex; consider a library like WatermelonDB or PowerSync.
Should I add pagination?
Yes, for apps with many todos. Modify getTodos() to accept a page parameter, return 10 todos per page, and use infinite scroll or pagination UI.