Next.js Cheatsheet

Server Actions

Use this Next.js reference while you build software engineering projects, review code for technical interview prep, or polish examples for a software engineer resume.

What Are Server Actions?

Server Actions are async functions that run on the server, callable from Client Components (via forms or event handlers) or Server Components. They replace manual API route boilerplate for mutations.

Defining Server Actions

Option 1 — Inline in a Server Component (single action):

// app/contact/page.tsx (Server Component)
export default function ContactPage() {
  async function sendMessage(formData: FormData) {
    'use server'                            // directive inside the function
    const message = formData.get('message') as string
    await db.messages.create({ data: { message } })
  }

  return (
    <form action={sendMessage}>
      <input name="message" />
      <button type="submit">Send</button>
    </form>
  )
}

Option 2 — Dedicated actions file (reusable across components):

// app/actions.ts  (or lib/actions.ts)
'use server'                              // directive at top of file

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  await db.posts.create({ data: { title } })
}

export async function deletePost(id: string) {
  await db.posts.delete({ where: { id } })
}

'use server' at the top of a file marks every export in that file as a Server Action.

Using Actions in a <form>

// Server Component — no JS needed on the client
import { createPost } from '@/app/actions'

export default function NewPostForm() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="Post title" required />
      <button type="submit">Create</button>
    </form>
  )
}

Calling Actions from Client Components

'use client'

import { createPost } from '@/app/actions'

export function PostForm() {
  return (
    <form action={createPost}>
      <input name="title" />
      <button type="submit">Create</button>
    </form>
  )
}

Via event handler (not a form):

'use client'

import { deletePost } from '@/app/actions'

export function DeleteButton({ id }: { id: string }) {
  return (
    <button onClick={() => deletePost(id)}>
      Delete
    </button>
  )
}

useActionState — Pending State + Return Value

'use client'

import { useActionState } from 'react'
import { createPost } from '@/app/actions'

const initialState = { message: '', error: '' }

export function CreatePostForm() {
  const [state, formAction, isPending] = useActionState(createPost, initialState)

  return (
    <form action={formAction}>
      <input name="title" />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating…' : 'Create'}
      </button>
      {state.message && <p>{state.message}</p>}
      {state.error && <p className="text-red-500">{state.error}</p>}
    </form>
  )
}
// app/actions.ts
'use server'

type State = { message: string; error: string }

export async function createPost(prevState: State, formData: FormData): Promise<State> {
  const title = formData.get('title') as string
  if (!title) return { message: '', error: 'Title is required' }

  await db.posts.create({ data: { title } })
  return { message: 'Post created!', error: '' }
}

useActionState was previously called useFormState (React 18 / Next 14). Use useActionState in React 19 / Next 15+.

useFormStatus — Submit Button State

'use client'

import { useFormStatus } from 'react-dom'

export function SubmitButton() {
  const { pending } = useFormStatus()
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Saving…' : 'Save'}
    </button>
  )
}
// Use inside a <form> that has an action
<form action={createPost}>
  <input name="title" />
  <SubmitButton />   {/* must be a child of the form */}
</form>

startTransition — Programmatic Trigger

'use client'

import { useTransition } from 'react'
import { updateUser } from '@/app/actions'

export function EditProfile({ user }: { user: User }) {
  const [isPending, startTransition] = useTransition()

  function handleSave() {
    startTransition(async () => {
      await updateUser({ id: user.id, name: 'New Name' })
    })
  }

  return (
    <button onClick={handleSave} disabled={isPending}>
      {isPending ? 'Saving…' : 'Save'}
    </button>
  )
}

Passing Extra Arguments with .bind()

// app/actions.ts
'use server'

export async function deletePost(id: string, formData: FormData) {
  await db.posts.delete({ where: { id } })
}
// component
import { deletePost } from '@/app/actions'

export function PostItem({ post }: { post: Post }) {
  const deleteWithId = deletePost.bind(null, post.id)
  return (
    <form action={deleteWithId}>
      <button type="submit">Delete</button>
    </form>
  )
}

Revalidating After Mutations

'use server'

import { revalidatePath, revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'

export async function createPost(formData: FormData) {
  const post = await db.posts.create({ data: { title: formData.get('title') as string } })

  revalidatePath('/posts')              // re-render /posts
  revalidateTag('posts')               // invalidate tagged fetches
  redirect(`/posts/${post.id}`)        // navigate after success
}

redirect() inside a Server Action throws internally — no return needed.

Error Handling

'use server'

export async function updateProfile(formData: FormData) {
  try {
    await db.users.update({ where: { id: formData.get('id') as string }, data: { name: formData.get('name') as string } })
    revalidatePath('/profile')
  } catch (err) {
    // Return error state (works with useActionState)
    return { error: 'Failed to update profile' }
  }
}

For unexpected errors, use error.tsx — unhandled throws are caught by the error boundary.

cookies and headers in Actions

'use server'

import { cookies } from 'next/headers'

export async function login(formData: FormData) {
  const email = formData.get('email') as string
  const token = await createSessionToken(email)

  const cookieStore = await cookies()
  cookieStore.set('session', token, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7,
  })
}

export async function logout() {
  const cookieStore = await cookies()
  cookieStore.delete('session')
  redirect('/login')
}

Security Considerations

ConcernDetails
AuthenticationAlways check auth inside the action — anyone can call an exposed action
CSRFNext.js adds automatic CSRF protection for same-origin form submissions
Input validationValidate/sanitize all formData inputs (use Zod or similar)
Rate limitingApply in-memory or Redis rate limits for expensive operations
'use server'

import { z } from 'zod'
import { getCurrentUser } from '@/lib/auth'

const schema = z.object({ title: z.string().min(1).max(200) })

export async function createPost(formData: FormData) {
  const user = await getCurrentUser()
  if (!user) throw new Error('Unauthorized')

  const parsed = schema.safeParse({ title: formData.get('title') })
  if (!parsed.success) return { error: parsed.error.flatten() }

  await db.posts.create({ data: { title: parsed.data.title, userId: user.id } })
  revalidatePath('/posts')
}

Optimistic Updates with useOptimistic

'use client'

import { useOptimistic, useTransition } from 'react'
import { toggleLike } from '@/app/actions'

export function LikeButton({ post }: { post: Post }) {
  const [optimisticLiked, setOptimisticLiked] = useOptimistic(post.liked)
  const [, startTransition] = useTransition()

  function handleClick() {
    startTransition(async () => {
      setOptimisticLiked(prev => !prev)   // instant UI update
      await toggleLike(post.id)            // actual server call
    })
  }

  return (
    <button onClick={handleClick}>
      {optimisticLiked ? '♥ Liked' : '♡ Like'}
    </button>
  )
}