Mutating Data
Overview
In the previous activity, you implemented search and pagination using URL search params and Next.js APIs. In this activity, you will continue building the portfolio project by adding the ability to create, update, and delete project information.
This activity focuses on the core patterns from Next.js Learn Chapter 11, adapted to your portfolio context:
- What React Server Actions are and how to use them to mutate data.
- How to work with forms and Server Components.
- Best practices for working with the native
FormDataobject, including type validation. - How to revalidate the client cache using the
revalidatePathAPI. - How to create dynamic route segments with specific IDs.
Prepare
Server Actions
React Server Actions are asynchronous functions that run on the server. In Next.js, they let you mutate data without creating separate REST endpoints for each write operation. In your portfolio app, this means you can create, update, and delete project records directly from form submissions.
Server-side mutations keep credentials and database logic on the backend. Even so, all incoming data should still be validated before it is written to the database.
// app/lib/actions.ts
'use server';
import { sql } from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
export async function deleteProject(id: string) {
await sql`DELETE FROM projects WHERE id = ${id}`;
revalidatePath('/projects');
}
Using Forms with Server Actions
A form can invoke a Server Action directly through the action attribute. On submit, Next.js passes the native FormData object to your server function.
// app/projects/create/page.tsx
import { createProject } from '@/app/lib/actions';
export default function Page() {
return (
<form action={createProject}>
<label htmlFor="title">Title</label>
<input id="title" name="title" required />
<label htmlFor="description">Description</label>
<textarea id="description" name="description" required />
<label htmlFor="technologies">Technologies (comma-separated)</label>
<input id="technologies" name="technologies" required />
<button type="submit">Save Project</button>
</form>
);
}
This pattern supports progressive enhancement, so the form can still submit in constrained browser environments.
Best Practices with FormData and Type Validation
FormData values are untyped and may be missing or malformed. Parse and validate input before writing to the database. Schema tools such as zod make this process reliable.
// app/lib/actions.ts
'use server';
import { z } from 'zod';
import { sql } from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
const ProjectFormSchema = z.object({
title: z.string().min(2),
description: z.string().min(10),
technologies: z.string().min(2),
});
export async function createProject(formData: FormData) {
const raw = {
title: formData.get('title'),
description: formData.get('description'),
technologies: formData.get('technologies'),
};
const parsed = ProjectFormSchema.safeParse(raw);
if (!parsed.success) {
throw new Error('Invalid project input.');
}
const { title, description, technologies } = parsed.data;
await sql`
INSERT INTO projects (title, description, technologies)
VALUES (${title}, ${description}, ${technologies})
`;
revalidatePath('/projects');
redirect('/projects');
}
Revalidating the Client Cache with revalidatePath
After a create, update, or delete action, cached route data may become stale. Call revalidatePath inside the Server Action so users see fresh data after mutation.
revalidatePath('/projects');
Dynamic Route Segments for Editing Specific IDs
Use a dynamic segment to edit a specific project record:
app/projects/[id]/edit/page.tsx
Read the id from route params, load that project, and submit the edit form to an update action bound to the same ID.
// app/projects/[id]/edit/page.tsx
import { updateProject } from '@/app/lib/actions';
export default async function Page(props: { params: Promise<{ id: string }> }) {
const params = await props.params;
const id = params.id;
// Fetch project by id and render the edit form...
return <form action={updateProject.bind(null, id)}>...</form>;
}
Activity Instructions
- Create a form to capture the user's input for a new project.
Where should these files go? In your portfolio project, create the new route and form files inside the
app/projectsfolder. A common structure isapp/projects/create/page.tsxfor the create form route andapp/projects/[id]/edit/page.tsxfor the edit form route. - Create a
createProjectServer Action and connect it to your create-project form using the formactionattribute. - Add validation for
title,description, andtechnologiesvalues fromFormDatabefore writing to the database (use theProjectFormSchema+safeParsepattern from the Prepare section). - Create an edit route at
app/projects/[id]/edit/page.tsxand wire it to anupdateProjectServer Action (useparams.idand pass it to the action withupdateProject.bind(null, id)as shown above). - Add a
deleteProjectServer Action and connect it to a delete button for each project row/card (submit the projectidso each delete only removes one record). - Call
revalidatePath('/projects')after each create, update, and delete action, then verify that the projects list refreshes immediately (place it right after the database write, thenredirect('/projects')when appropriate).