Server Actions, Prisma, and PostgreSQL in a Next.js App

8 min read
Server room with blue lights

Full-stack Next.js is actually good now

This site was a static blog for a long time. Markdown files, static generation, deploy and forget. Then I wanted a like counter on blog posts. Then a guest wall where visitors could leave messages. Suddenly I needed a database and server-side logic, and I kept putting it off because I assumed it would be a pain to set up.

It wasn't. Server actions with Prisma and PostgreSQL turned out to be the least painful full-stack setup I've used. Here's the actual code from this repo.

The Prisma schema

Three models. That's been enough for everything:

datasource db { provider = "postgresql" url = env("POSTGRES_PRISMA_URL") directUrl = env("POSTGRES_URL_NON_POOLING") } model Post { id Int @id @default(autoincrement()) title String @unique @db.VarChar(255) createdAt DateTime @default(now()) @db.Timestamp(6) Likes Like[] } model Like { id Int @id @default(autoincrement()) userId String @db.VarChar(255) createdAt DateTime @default(now()) @db.Timestamp(6) post Post @relation(fields: [postId], references: [id]) postId Int } model GuestMessage { id Int @id @default(autoincrement()) name String @db.VarChar(100) message String @db.VarChar(500) emoji String @default("👋") @db.VarChar(10) createdAt DateTime @default(now()) @db.Timestamp(6) }

Two connection strings

The datasource has both url and directUrl. This confused me for a while. POSTGRES_PRISMA_URL goes through PgBouncer (Vercel's connection pooler) which is what you want for serverless because otherwise every function invocation opens a new connection and you hit the limit fast. POSTGRES_URL_NON_POOLING is a direct connection used only for migrations, because DDL statements don't work through a pooler.

I spent an hour debugging migration failures before I figured this out. The app worked fine, queries ran fine, but prisma migrate deploy kept hanging. Turns out it was trying to run CREATE TABLE through the pooler. Adding directUrl fixed it immediately.

The like counter end-to-end

Every blog post has a thumbs-up button on the right side. It's three files working together: a server component that fetches the data, a client component that handles clicks, and a server action that writes to the database.

The server component reads a cookie to identify the user, then fires two Prisma queries in parallel:

// app/blog/[slug]/like-counter.tsx export async function LikeCounterServer({ blogTitle }: { blogTitle: string }) { const cookieStore = await cookies(); const [postLikes, hasUserLikedAlready] = await Promise.all([ getLikeCount(blogTitle), getIsPostLikedByUser(cookieStore.get('uuid')?.value, blogTitle), ]); return ( <LikeCounter hasUserLikedAlready={hasUserLikedAlready} likes={postLikes} blogTitle={blogTitle} /> ); }

The Promise.all matters. Two independent database queries, so run them at the same time instead of waiting for one to finish before starting the other. This whole component is wrapped in Suspense on the blog post page, so the article renders immediately and the like count streams in when the database responds.

The client component is a form. Click the button, it optimistically bumps the count and fires the server action in the background. There's a useRef counter that caps it at 10 likes per page load to stop casual spam:

// app/blog/[slug]/like-counter.client.tsx 'use client'; const MAX_LIKES_PER_READ = 10; export function LikeCounter({ likes, blogTitle, hasUserLikedAlready }) { const [localLikes, addLocalLikes] = useState(likes); const countRef = useRef(0); function handleIconClick() { countRef.current += 1; if (countRef.current >= MAX_LIKES_PER_READ) return; addLocalLikes((prev) => prev + 1); } return ( <form action={(formData) => { if (countRef.current >= MAX_LIKES_PER_READ) return; addLikesToPost(formData); }}> <input type="hidden" name="blogTitle" value={blogTitle} /> <button type="submit" onClick={handleIconClick}>👍 {localLikes}</button> </form> ); }

And the server action itself is tiny:

// app/blog/[slug]/like-counter.actions.ts 'use server'; export async function addLikesToPost(formData: FormData) { const blogTitle = formData.get('blogTitle') as string; if (!blogTitle) return; const cookieStore = await cookies(); const uuid = cookieStore.get('uuid'); if (!uuid) return; await likePost(uuid.value, blogTitle); }

'use server' at the top. That's the whole thing. No API route, no fetch, no JSON parsing. Next.js serializes the form data, sends it to the server, runs the function, done. The Prisma call underneath is just prisma.like.create() with a relation connect.

The userId situation

The uuid comes from a cookie set on first visit. It's a random string stored in the browser. Not secure, not unique across devices, trivially spoofable. I know.

For a like counter on a personal blog, I genuinely don't care. If someone clears their cookies and likes a post again, the world keeps spinning. If I were building something where accuracy mattered I'd use authentication, but adding a whole auth system for a thumbs-up button felt like massive overkill.

The guest wall

This one is more interesting because it uses two React 19 hooks I hadn't touched before: useOptimistic and useActionState.

The page component fetches messages server-side and passes them down:

// app/guest-wall/page.tsx export default async function GuestWallPage() { const messages = await prisma.guestMessage.findMany({ orderBy: { createdAt: 'desc' }, take: 200, }); return <GuestWallClient initialMessages={messages} />; }

The client component is where it gets fun. When you submit a message, it shows up in the UI instantly via useOptimistic before the server action even starts running. If the action fails, the optimistic message disappears and an error shows up. If it succeeds, revalidatePath triggers a re-fetch and the optimistic data gets replaced with the real database data.

'use client'; export function GuestWallClient({ initialMessages }) { const [state, formAction, isPending] = useActionState(addGuestMessage, null); const [optimisticMessages, addOptimisticMessage] = useOptimistic( initialMessages, (current, newMessage) => [newMessage, ...current] ); const handleSubmit = (formData) => { addOptimisticMessage({ id: Date.now(), name: formData.get('name').trim(), message: formData.get('message').trim(), emoji: selectedEmoji, createdAt: new Date(), }); formAction(formData); formRef.current?.reset(); }; // ... }

useOptimistic takes the real server data and a reducer. Call addOptimisticMessage and the UI updates immediately. When the server responds, React replaces the optimistic state with the fresh data. I didn't have to write any of the reconciliation logic. It just works.

useActionState gives you the previous return value of the server action. I use it for error handling. The server action returns { error: 'some message' } on validation failure, and the client reads state?.error to show it.

Validation and rate limiting

The guest wall server action does more than the like counter. It validates inputs, rate-limits by IP, sanitizes the emoji, and calls revalidatePath after a successful write:

'use server'; const rateLimitMap = new Map<string, number>(); export async function addGuestMessage(prevState, formData) { const name = (formData.get('name') as string)?.trim(); const message = (formData.get('message') as string)?.trim(); if (!name || name.length > 100) return { error: 'Name is required and must be under 100 characters.' }; if (!message || message.length > 500) return { error: 'Message is required and must be under 500 characters.' }; const ip = (await headers()).get('x-forwarded-for') || 'unknown'; const lastSubmit = rateLimitMap.get(ip); if (lastSubmit && Date.now() - lastSubmit < 60_000) return { error: 'Please wait a minute before posting again.' }; rateLimitMap.set(ip, Date.now()); await prisma.guestMessage.create({ data: { name, message, emoji } }); revalidatePath('/guest-wall'); return { success: true }; }

The rate limiting is an in-memory Map. There's a comment in the actual code acknowledging this doesn't really work in serverless because each invocation might be a different instance. For a personal blog with low traffic it catches the obvious case of someone mashing the submit button. If I cared more I'd use Redis or Vercel KV.

Why server actions instead of API routes

I could have built all of this with app/api/likes/route.ts and app/api/guest-messages/route.ts. That's how I would have done it before server actions existed. Fetch from the client, parse JSON, handle errors, the usual.

Server actions are less code for this kind of thing. No manual fetch calls, no JSON serialization, no CORS headers. The form works even without JavaScript (progressive enhancement for free). TypeScript types flow through automatically. And error handling is just returning an object from the function instead of setting HTTP status codes.

The trade-off is that server actions are form-centric. If you need to call something from a random event handler that isn't a form submission, it gets a bit awkward with startTransition. For likes and guest messages, forms are the natural fit, so it wasn't an issue.

Migrations on Vercel

When I added the GuestMessage model I ran npx prisma migrate dev --name add-guest-messages locally. Prisma generated a SQL migration file in prisma/migrations/. On deploy, npx prisma migrate deploy runs during the Vercel build step and applies any pending migrations using the direct (non-pooled) connection.

I've done four migrations total. Post table, Likes table, GuestMessage table, and adding the emoji column to GuestMessage after I decided the wall needed more personality than just text.

What I'd change

The like counter doesn't prevent duplicate likes well. The userId is a cookie, so clearing cookies lets you like again. A unique constraint on (userId, postId) would fix it but I haven't bothered.

I should set up prisma/seed.ts for dev environments. Every time I clone fresh I have to manually create test data. Ten minutes of work I keep not doing.

The rate limiting should be in Redis, not in-memory. I know this. I'll fix it when someone actually abuses it.


Wrapping up

Server component fetches data. Client component handles interaction. Server action mutates data. Prisma talks to PostgreSQL. revalidatePath refreshes the page after mutations. That's the whole pattern.

The like counter code is in app/blog/[slug]/ and the guest wall is in app/guest-wall/. All open source: github.com/hannadrehman/blog-nextjs

Share:TwitterLinkedIn