Logo Xingxin on Bug

Diagram Mutating Data in Next.js

May 12, 2025
4 min read

This is what I learned from App Router: Mutating Data. The diagrams greatly helped me understand the CRUD operations better.

mutating-data-in-next-js-cud.webp

In this section, I explored

  • C: create✨
  • U: update♻️
  • D: delete🧹

💻Server Function

Before diving into the details, it’s important to understand that Next.js has 2 types of components:

  1. client component
  2. server component

learn-client-and-server-environments.avif

©️vercel

Since the C, U, D all interact with the database, they are implemented as server function. There are 2 patterns to define a server function in Next.js.

1️⃣ mark the function

import Button from './Button';
 
function EmptyNote () {
  async function createNoteAction() {
    // Server Function
    'use server';  //👈this directive marks this function as server function
    
    await db.notes.create();
  }
  return <Button onClick={createNoteAction}/>;
}

2️⃣ mark the file Alternatively, you can create a file like actions.ts and add the directive 'use server' at the top,

'use server';
 
export async function createInvoice(formData: FormData) {}
export async function updateInvoice(id: string, formData: FormData) {}
export async function deleteInvoice(id: string) {}

all functions in this file will be server functions. This approach keeps all server functions organized in a single file, which is a great way to maintain clarity. 👍

🗂️Schema

The data submitted through forms is FormData. To ensure proper type validation, for example a number twelve is 12 but not "12", we use Zod to define and validate the schema.

const FormSchema = z.object({
  id: z.string(),
  customerId: z.string(),
  amount: z.coerce.number(),
  status: z.enum(['pending', 'paid']),
  date: z.string(),
});

✨Create

Let’s examine the “Create” workflow.

mutating-data-in-next-js-create-workflow.webp

There are 2 main routes:

  • 🟥User clicks the “Create Invoice” button, they are navigated to ...com/dashboard/invoices/create
  • 🟦After the form is filled out and submitted to the database, the user is redirected back to ...com/dashboard/invoices

The first route is straightforward, using a <Link/> component to navigate. I am particularly interested in the second one:

  1. how do we ensure the data is fresh when returning to the ...com/dashboard/invoices
  2. how do we go back to ...com/dashboard/invoices

The answer lies in the server function createInvoice() which is bound to the form.

<form action={createInvoice}>
  //all the data wrapped in FormData
</form>

When user clicks the “Create Invoice” button in 🟦, this server function will:

  1. parse the form data
  2. execute the sql code to insert the data into the database
  3. revalidatePath() will “refresh” the table, ensuring it displays the latest
  4. redirect() will navigate back to the invoices page
export async function createInvoice(formData: FormData) {
  //parse FormData
  //..
 
  //SQL
  await sql`...`;
 
  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

♻️Update

mutating-data-in-next-js-update.webp

The “Update” workflow is quite similar.

export async function updateInvoice(id: string, formData: FormData) {
  //parse FormData
  //..
 
  //SQL
  await sql`...`;
 
  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

One key aspect to highlight is Dynamic Routes. The invoice UUID c6a48b88-67b8-4747-8fb1-295aa1074a58 is dynamic and only known at runtime. To handle this, we use the following file structure.

- 📂invoice
    - 📂[id]
        - 📂edit
            - 📄page.tsx

This structure results in a URL like ..com/dashboard/invoices/[id]/edit

🧹Delete

The “Delete” workflow follows a similar pattern, but there is an important consideration common to all Create/Update/Delete operations.

The following approach is not safe.

import { updateInvoice } from '@/app/lib/actions';
export function DeleteInvoice({ id }: { id: string }) {
 
  return (
    <form action={deleteInvoice(id)}>
    //...
    </form>
  );
}

Since the Create/Update/Delete buttons are all client component, passing the id directly exposes it in the client-side code, which is dangerous💀!

Instead, use the bind() to securely pass the id to the server function:

import { updateInvoice } from '@/app/lib/actions';
 
export function DeleteInvoice({ id }: { id: string }) {
  const deleteInvoiceWithId = deleteInvoice.bind(null, id);
  return (
    <form action={deleteInvoiceWithId}>
    //...
    </form>
  );
}

Using bind() ensures the id is safely encoded and not exposed in the client-side HTML.