Creating Posts with Next.js Server Actions
Step 16 of 31 — Next.js Tutorial Series | Source code for this step
What You Will Build
By the end of this step, authenticated users can create new blog posts through a form at /posts/new. The form uses a Server Action — a function that runs on the server when the form is submitted.
Goal: Learn how Server Actions work, build a form that creates posts, and redirect to the new post after creation.
Table of Contents
- What is a Server Action?
- Server Actions vs API Routes
- Core Concept:
'use server' - Core Concept:
FormData - Create the Server Action
- Build the Create Post Form
- Understanding the Code
- Verify the Result
- Common Mistakes
- Summary & Key Takeaways
What is a Server Action?
A Server Action is an async function that runs on the server in response to a user interaction (like submitting a form). You define the function in your code and pass it to a form's action prop — Next.js handles the rest.
async function createPost(formData: FormData) {
'use server'
// This code runs on the server
const title = formData.get('title') as string
await prisma.post.create({ data: { title } })
}
return <form action={createPost}>...</form>
When the form is submitted, Next.js serializes the form data, sends it to the server, runs the function, and returns the result — all without you writing an API route, a fetch() call, or handling JSON.
Server Actions vs API Routes
In Step 11, we created API routes (app/api/posts/route.ts) that accept JSON requests. Server Actions are an alternative approach:
| API Route | Server Action | |
|---|---|---|
| Where you define it | app/api/.../route.ts | Inside or alongside your component |
| How to call it | fetch('/api/...') from client | Pass to <form action={...}> or call directly |
| Request format | JSON body | FormData object |
| Use case | External APIs, mobile apps, third-party consumers | Forms, user interactions within your Next.js app |
| Authentication | Check auth() manually | Check auth() manually |
When to use which:
- Use Server Actions for forms and user interactions within your app — they are simpler and require less boilerplate.
- Use API routes when you need a public API that other clients can consume.
Both approaches are valid. In a real app, you often use both.
Core Concept: 'use server'
The 'use server' directive marks a function as a Server Action. It tells Next.js: "This function must only run on the server. Never send this code to the browser."
There are two ways to use it:
1. Inline — inside a Server Component:
export default async function Page() {
async function handleSubmit(formData: FormData) {
'use server'
// This runs on the server
}
return <form action={handleSubmit}>...</form>
}
2. In a separate file — for use in Client Components:
// app/actions.ts
'use server'
export async function createPost(formData: FormData) {
// This runs on the server
}
When 'use server' is at the top of a file, every exported function in that file becomes a Server Action. This is the approach we will use because it keeps the action logic separate from the UI.
Core Concept: FormData
When a form is submitted to a Server Action, the form fields are collected into a FormData object — the browser's built-in way of packaging form data.
<form action={myAction}>
<input name="title" />
<textarea name="content" />
<button type="submit">Submit</button>
</form>
Inside the action, you read the fields with .get():
async function myAction(formData: FormData) {
'use server'
const title = formData.get('title') as string // "My Post"
const content = formData.get('content') as string // "Hello world"
}
The name attribute on each input must match the key you pass to .get(). If you forget the name attribute, the field will not be included in the FormData.
Create the Server Action
Create a new file app/actions.ts:
'use server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
const session = await auth()
if (!session?.user?.email) {
throw new Error('You must be signed in to create a post')
}
const title = formData.get('title') as string
const content = formData.get('content') as string
if (!title || !content) {
throw new Error('Title and content are required')
}
// Find the user by email to get their ID
const user = await prisma.user.findUnique({
where: { email: session.user.email },
})
if (!user) {
throw new Error('User not found')
}
const post = await prisma.post.create({
data: {
title,
content,
authorId: user.id,
},
})
redirect(`/posts/${post.id}`)
}
What this action does
- Check authentication — read the session with
auth(). If not signed in, throw an error. - Read form data — extract
titleandcontentfrom the submitted form. - Validate — make sure both fields are present.
- Find the user — look up the user ID from the session email.
- Create the post — insert into the database with the author's ID.
- Redirect — send the user to the new post's detail page.
Build the Create Post Form
Replace the entire contents of app/posts/new/page.tsx:
import type { Metadata } from 'next'
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { createPost } from '@/app/actions'
export const metadata: Metadata = {
title: 'Create New Post — Superblog',
description: 'Write and publish a new blog post',
}
export default async function NewPostPage() {
const session = await auth()
if (!session) {
redirect('/login')
}
return (
<>
<h2 className="mb-4 text-3xl font-bold text-gray-900">
Create New Post
</h2>
<p className="mb-8 text-gray-600">
Signed in as {session.user?.email}
</p>
<form action={createPost} className="p-6 bg-white rounded-lg shadow-md">
<div className="space-y-4">
<div>
<label
htmlFor="title"
className="block mb-1 text-sm font-medium text-gray-700"
>
Title
</label>
<input
id="title"
name="title"
type="text"
required
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-teal-500"
placeholder="Your post title"
/>
</div>
<div>
<label
htmlFor="content"
className="block mb-1 text-sm font-medium text-gray-700"
>
Content
</label>
<textarea
id="content"
name="content"
required
rows={8}
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-teal-500"
placeholder="Write your post content..."
/>
</div>
<button
type="submit"
className="px-4 py-2 text-white bg-teal-600 rounded hover:bg-teal-700"
>
Create Post
</button>
</div>
</form>
</>
)
}
Understanding the Code
'use server' at the file level
// app/actions.ts
'use server'
export async function createPost(formData: FormData) { ... }
Because 'use server' is at the top of actions.ts, every exported function in the file is a Server Action. This is different from 'use client' — with 'use server', the code never reaches the browser. Next.js generates a special URL endpoint for each action behind the scenes.
The form action prop
<form action={createPost}>
In traditional HTML, the action attribute is a URL (action="/api/submit"). In Next.js with Server Actions, you pass a function instead. Next.js handles the form submission automatically — no fetch(), no preventDefault(), no JSON.stringify().
No 'use client' needed
Notice that this page does not have 'use client'. The form works without any client-side JavaScript. When the user clicks "Create Post":
- The browser submits the form natively (like traditional HTML forms).
- Next.js intercepts the submission and calls the Server Action.
- The Server Action runs on the server, creates the post, and calls
redirect(). - The browser navigates to the new post's page.
This is called progressive enhancement — the form works even if JavaScript fails to load.
redirect() in a Server Action
redirect(`/posts/${post.id}`)
redirect() from next/navigation works inside Server Actions. It throws a special exception that Next.js catches to trigger a client-side navigation. You do not need return after redirect() — it never returns.
Why we look up the user by email
const user = await prisma.user.findUnique({
where: { email: session.user.email },
})
The session contains user.email but not user.id by default (NextAuth's JWT only includes name, email, and image). We need the user's id to set authorId on the post. So we query the database to get it.
An alternative is to add id to the JWT token using NextAuth callbacks — but that is an advanced topic. The database lookup is simple and reliable.
Double protection
The form is protected twice:
- Middleware (Step 15) — redirects unauthenticated users away from
/posts/newbefore the page even loads. - Server Action — checks
auth()again before creating the post. This protects against direct API calls that bypass the UI.
Always protect on the server — never rely only on hiding UI elements.
Verify the Result
Make sure your dev server is running:
pnpm dev
- Sign in to your account.
- Click "New Post" in the Header (visible only when signed in).
- Enter a title and content.
- Click "Create Post".
- You should be redirected to the new post's detail page (
/posts/:id). - Navigate to
/posts— your new post should appear at the top of the list.
Test without authentication
- Sign out.
- Try visiting
http://localhost:3000/posts/newdirectly. You should be redirected to/login(middleware protection).
Common Mistakes
Forgetting the name attribute
// ❌ No name — the field will not appear in FormData
<input type="text" />
// ✅ Name is required — it becomes the key in formData.get('title')
<input name="title" type="text" />
Putting 'use server' in a Client Component
// ❌ This will NOT work
'use client'
async function createPost(formData: FormData) {
'use server' // Error: cannot use 'use server' in a Client Component file
}
Server Actions must be defined in a Server Component or in a separate file with 'use server' at the top. Import the action into the Client Component instead:
// ✅ Define in a separate file
// app/actions.ts
'use server'
export async function createPost(formData: FormData) { ... }
// app/posts/new/page.tsx (Client Component)
'use client'
import { createPost } from '@/app/actions'
Forgetting server-side validation
// ❌ Trust the client — anyone can send arbitrary data
const post = await prisma.post.create({
data: { title: formData.get('title') as string },
})
// ✅ Always validate on the server
const title = formData.get('title') as string
if (!title) throw new Error('Title is required')
Even though the form has required attributes, those are client-side only. A malicious user can bypass them. Always validate inside the Server Action.
Summary & Key Takeaways
| Concept | What it means |
|---|---|
| Server Action | An async function that runs on the server in response to a form submission |
'use server' | Marks a function or file as server-only — code never reaches the browser |
FormData | The browser's built-in form data object — fields accessed with .get('name') |
form action={fn} | Passes a Server Action to a form — no fetch() or API route needed |
redirect() | Triggers client-side navigation from a Server Action |
| Progressive enhancement | Forms work without client-side JavaScript |
| Double protection | Middleware protects the route; Server Action protects the data mutation |
What is Next
In Step 17, we will add a delete button to each post, completing the CRUD cycle. Users will only be able to delete their own posts.