Write the Create & Update Data Functions and Server Actions
Write the Create & Update Data Functions and Server Actions
Add a shared input type
Open lib/types.ts and add:
export type ContactInput = {
name: string;
phone?: string;
email?: string;
company?: string;
notes?: string;
favorite?: boolean;
};
This is the shape of data coming from a form — everything except name is
optional, since a contact doesn’t need a phone, email, company, or notes.
Write createContact and updateContact
Open lib/contacts.ts and add:
import type { Contact, ContactInput } from "./types";
export async function createContact(input: ContactInput): Promise<Contact> {
const result = await pool.query(
`INSERT INTO contacts (name, phone, email, company, notes, favorite)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
[
input.name,
input.phone || null,
input.email || null,
input.company || null,
input.notes || null,
!!input.favorite,
]
);
return result.rows[0];
}
export async function updateContact(id: string, input: ContactInput): Promise<Contact> {
const result = await pool.query(
`UPDATE contacts SET
name = $1, phone = $2, email = $3, company = $4, notes = $5,
favorite = $6, updated_at = now()
WHERE id = $7 RETURNING *`,
[
input.name,
input.phone || null,
input.email || null,
input.company || null,
input.notes || null,
!!input.favorite,
id,
]
);
return result.rows[0];
}
Both RETURNING * clauses hand back the row exactly as it now exists in
the database — useful in a moment, when we redirect to the new contact’s
own detail page and need its id.
Write the Server Actions
Create lib/actions.ts:
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { createContact, updateContact } from "./contacts";
function readContactInput(formData: FormData) {
return {
name: String(formData.get("name") || "").trim(),
phone: String(formData.get("phone") || "").trim(),
email: String(formData.get("email") || "").trim(),
company: String(formData.get("company") || "").trim(),
notes: String(formData.get("notes") || "").trim(),
favorite: formData.get("favorite") === "on",
};
}
export async function createContactAction(formData: FormData) {
const input = readContactInput(formData);
if (!input.name) {
throw new Error("Name is required.");
}
const contact = await createContact(input);
revalidatePath("/");
redirect(`/contacts/${contact.id}`);
}
export async function updateContactAction(id: string, formData: FormData) {
const input = readContactInput(formData);
if (!input.name) {
throw new Error("Name is required.");
}
await updateContact(id, input);
revalidatePath("/");
revalidatePath(`/contacts/${id}`);
redirect(`/contacts/${id}`);
}
The "use server" directive at the top of the file is what makes every
exported function here callable directly from a <form action={...}> in a
Server Component, or from a Client Component — Next.js turns each one into
a secure endpoint automatically. There’s no route you define, no fetch
call you write, and no JSON you serialize by hand.
A checkbox’s FormData value is the string "on" when checked and
missing entirely when unchecked — that’s why favorite is read as
formData.get("favorite") === "on" rather than treated as a boolean
directly.
revalidatePath("/") tells Next.js “the data behind this path is stale,
refetch it next time it’s requested” — without it, the Home page would
keep showing its old, cached list after you add a contact. redirect(...)
sends the browser straight to the new contact’s detail page once the
Server Action finishes.
Checkpoint
There’s no UI wired up to these yet — that’s the next lesson. For now,
confirm the file compiles with no TypeScript errors and that lib/actions.ts
starts with "use server" as its very first line (it must be the first
line in the file, before any imports, or Next.js won’t treat it as a
Server Actions module).