Server Actions
Time to start creating posts. This means POST
ing data to the server so we can store it in the database.
export default function CreatePost() {
return (
<main className="text-center mt-10">
<form className="border border-neutral-500 rounded-lg px-6 py-4 flex flex-col gap-4">
<label className="w-full">
<textarea
className="bg-transparent flex-1 border-none outline-none w-full"
name="content"
placeholder="Post a thing..."
required
/>
</label>
<button type="submit" className="border rounded-xl px-4 py-2">
Post
</button>
</form>
</main>
)
}
export default function CreatePost() {
return (
<main className="text-center mt-10">
<form className="border border-neutral-500 rounded-lg px-6 py-4 flex flex-col gap-4">
<label className="w-full">
<textarea
className="bg-transparent flex-1 border-none outline-none w-full"
name="content"
placeholder="Post a thing..."
required
/>
</label>
<button type="submit" className="border rounded-xl px-4 py-2">
Post
</button>
</form>
</main>
)
}
export default function CreatePost() {
return (
<main className="text-center mt-10">
<form className="border border-neutral-500 rounded-lg px-6 py-4 flex flex-col gap-4">
<label className="w-full">
<textarea
className="bg-transparent flex-1 border-none outline-none w-full"
name="content"
placeholder="Post a thing..."
required
/>
</label>
<button type="submit" className="border rounded-xl px-4 py-2">
Post
</button>
</form>
</main>
)
}
export default function CreatePost() {
return (
<main className="text-center mt-10">
<form className="border border-neutral-500 rounded-lg px-6 py-4 flex flex-col gap-4">
<label className="w-full">
<textarea
className="bg-transparent flex-1 border-none outline-none w-full"
name="content"
placeholder="Post a thing..."
required
/>
</label>
<button type="submit" className="border rounded-xl px-4 py-2">
Post
</button>
</form>
</main>
)
}
<textarea
className="bg-transparent flex-1 border-none outline-none w-full"
name="content"
placeholder="Post a thing..."
required
/>
<textarea
className="bg-transparent flex-1 border-none outline-none w-full"
name="content"
placeholder="Post a thing..."
required
/>
<textarea
className="bg-transparent flex-1 border-none outline-none w-full"
name="content"
placeholder="Post a thing..."
required
/>
<textarea
className="bg-transparent flex-1 border-none outline-none w-full"
name="content"
placeholder="Post a thing..."
required
/>
The default behaviour of this form is not very helpful, it makes a GET
request to the same page, which is not what we want.
Submitting a Form
When we submit the form, here's what should happen:
- Make a
POST
request to our backend with the form data - The backend code should create a new post in the database
- On success, redirect the user to the home page feed
There are two common ways of achieving this:
export default function CreatePost() {
return (
<main className="text-center mt-10">
<form action="/create-post" method="POST">
...
</form>
</main>
)
}
export default function CreatePost() {
return (
<main className="text-center mt-10">
<form action="/create-post" method="POST">
...
</form>
</main>
)
}
export default function CreatePost() {
return (
<main className="text-center mt-10">
<form action="/create-post" method="POST">
...
</form>
</main>
)
}
export default function CreatePost() {
return (
<main className="text-center mt-10">
<form action="/create-post" method="POST">
...
</form>
</main>
)
}
"use client"
export default function CreatePost() {
return (
<main className="text-center mt-10">
<form
onSubmit={async (event) => {
event.preventDefault()
await fetch("/create-post", {
method: "POST",
body: new FormData(event.target),
})
}}
>
...
</form>
</main>
)
}
"use client"
export default function CreatePost() {
return (
<main className="text-center mt-10">
<form
onSubmit={async (event) => {
event.preventDefault()
await fetch("/create-post", {
method: "POST",
body: new FormData(event.target),
})
}}
>
...
</form>
</main>
)
}
"use client"
export default function CreatePost() {
return (
<main className="text-center mt-10">
<form
onSubmit={async (event) => {
event.preventDefault()
await fetch("/create-post", {
method: "POST",
body: new FormData(event.target),
})
}}
>
...
</form>
</main>
)
}
"use client"
export default function CreatePost() {
return (
<main className="text-center mt-10">
<form
onSubmit={async (event) => {
event.preventDefault()
await fetch("/create-post", {
method: "POST",
body: new FormData(event.target),
})
}}
>
...
</form>
</main>
)
}
Either way, we need to create some sort of server side endpoint that can handle the POST request and communicate with the database. How would you go about implementing that kind of endpoint?
- Would you try to make a RESTful API and do it wrong?
- Use graphql because that's what all the job postings say?
- Use trpc because someone said that's the new cool thing to do?
- How should you organize your backend JSON api files?
- Is JSON even the right format to use?
- What about serverless and edge and microservices?
- What even is HTTP anyways?
There's a lot of useless questions that come up when trying to implement some sort of backend endpoint.
And no matter what questions we ask or decisions we make, we need to end up with something like this.
A function that takes receives the data from the client and stores in a database.
export async function POST(request: Request) {
const data = await request.json()
await db.insert(posts).values(data)
redirect("/")
}
export async function POST(request: Request) {
const data = await request.json()
await db.insert(posts).values(data)
redirect("/")
}
export async function POST(request: Request) {
const data = await request.json()
await db.insert(posts).values(data)
redirect("/")
}
export async function POST(request: Request) {
const data = await request.json()
await db.insert(posts).values(data)
redirect("/")
}
So wouldn't it be nice if we could simplify this entire process and only focus on the backend code? No unnecessary questions or decisions, just focus on the core logic that we need to write.
Next.js Server Actions
In Next.js, we have the ability to use something called server actions
export default function CreatePost() {
async function handleCreatePost(data: FormData) {
"use server"
const content = data.get("content")
console.log("Create new post", content)
}
return (
<main className="text-center mt-10">
<form action={handleCreatePost}>
...
</form>
</main>
)
}
export default function CreatePost() {
async function handleCreatePost(data: FormData) {
"use server"
const content = data.get("content")
console.log("Create new post", content)
}
return (
<main className="text-center mt-10">
<form action={handleCreatePost}>
...
</form>
</main>
)
}
export default function CreatePost() {
async function handleCreatePost(data: FormData) {
"use server"
const content = data.get("content")
console.log("Create new post", content)
}
return (
<main className="text-center mt-10">
<form action={handleCreatePost}>
...
</form>
</main>
)
}
export default function CreatePost() {
async function handleCreatePost(data: FormData) {
"use server"
const content = data.get("content")
console.log("Create new post", content)
}
return (
<main className="text-center mt-10">
<form action={handleCreatePost}>
...
</form>
</main>
)
}
Now if you submit the form, you should see the console log in the server console. Submitting the form sends the form data in a POST request to an endpoint on the server that next.js setup for us automatically.
How Do Server Actions Work?
Server actions must be defined with "use server"
at the top of the function or the top of the file in which it's defined.
async function handleCreatePost(data: FormData) {
"use server"
}
async function handleCreatePost(data: FormData) {
"use server"
}
async function handleCreatePost(data: FormData) {
"use server"
}
async function handleCreatePost(data: FormData) {
"use server"
}
Since this function will receive data from a form, it's first argument must be of type FormData
and we can use the FormData
API.
async function handleCreatePost(data: FormData) {
"use server"
const content = data.get("content")
}
async function handleCreatePost(data: FormData) {
"use server"
const content = data.get("content")
}
async function handleCreatePost(data: FormData) {
"use server"
const content = data.get("content")
}
async function handleCreatePost(data: FormData) {
"use server"
const content = data.get("content")
}
We can then pass the server action to a form's action prop. Then next.js will automatically handle the form submission for us. It creates an HTTP endpoint and makes sure the form data is sent to that endpoint.
export default function CreatePost() {
return (
<main className="text-center mt-10">
<form action={handleCreatePost}>
...
</form>
</main>
)
}
export default function CreatePost() {
return (
<main className="text-center mt-10">
<form action={handleCreatePost}>
...
</form>
</main>
)
}
export default function CreatePost() {
return (
<main className="text-center mt-10">
<form action={handleCreatePost}>
...
</form>
</main>
)
}
export default function CreatePost() {
return (
<main className="text-center mt-10">
<form action={handleCreatePost}>
...
</form>
</main>
)
}
Essentially we've just made an API endpoint and sent a POST request to it. But instead of having to do all of this manually, all we have to do is define asynchronous server functions that can be called directly from your components. This form submission works with or without client side JS enabled, which gives us Progressive Enhancement.
Save The Post to The Database
import {db } from "@/db"
import { posts } from "@/db/schema/posts"
async function handleSubmit(data: FormData) {
"use server"
const content = data.get("content") as string
const result = await db.insert(posts).values({
content,
userId: "user-1"
}).returning()
console.log(result)
}
import {db } from "@/db"
import { posts } from "@/db/schema/posts"
async function handleSubmit(data: FormData) {
"use server"
const content = data.get("content") as string
const result = await db.insert(posts).values({
content,
userId: "user-1"
}).returning()
console.log(result)
}
import {db } from "@/db"
import { posts } from "@/db/schema/posts"
async function handleSubmit(data: FormData) {
"use server"
const content = data.get("content") as string
const result = await db.insert(posts).values({
content,
userId: "user-1"
}).returning()
console.log(result)
}
import {db } from "@/db"
import { posts } from "@/db/schema/posts"
async function handleSubmit(data: FormData) {
"use server"
const content = data.get("content") as string
const result = await db.insert(posts).values({
content,
userId: "user-1"
}).returning()
console.log(result)
}
Test this out by trying to create a new post, you should see the new post appear in the database and the server console.
Redirecting
Once we've created a new post, let's redirect the user back to the home page so they can see the new post appear in the feed.
import { redirect } from 'next/navigation'
async function handleSubmit(data: FormData) {
"use server"
const content = data.get("content") as string
const result = await db.insert(posts).values({
content,
userId: "user-1"
}).returning()
redirect("/")
}
import { redirect } from 'next/navigation'
async function handleSubmit(data: FormData) {
"use server"
const content = data.get("content") as string
const result = await db.insert(posts).values({
content,
userId: "user-1"
}).returning()
redirect("/")
}
import { redirect } from 'next/navigation'
async function handleSubmit(data: FormData) {
"use server"
const content = data.get("content") as string
const result = await db.insert(posts).values({
content,
userId: "user-1"
}).returning()
redirect("/")
}
import { redirect } from 'next/navigation'
async function handleSubmit(data: FormData) {
"use server"
const content = data.get("content") as string
const result = await db.insert(posts).values({
content,
userId: "user-1"
}).returning()
redirect("/")
}
Notice that the redirect works, but the new post doesn't appear in the feed.
Next.js will try to cache everything. The first time we loaded the home page feed, a request was made to the database to get all the posts. Next cached the result so that every time we make that same request to the database to get all posts, it will return the cached result instead of making a new request to the database. This is great for improving performance and reducing cost, but not great for our use case.
Revalidating
import { revalidatePath } from 'next/cache'
async function handleSubmit(data: FormData) {
"use server"
const content = data.get("content") as string
const result = await db.insert(posts).values({
content,
userId: "user-1"
}).returning()
revalidatePath("/")
redirect("/")
}
import { revalidatePath } from 'next/cache'
async function handleSubmit(data: FormData) {
"use server"
const content = data.get("content") as string
const result = await db.insert(posts).values({
content,
userId: "user-1"
}).returning()
revalidatePath("/")
redirect("/")
}
import { revalidatePath } from 'next/cache'
async function handleSubmit(data: FormData) {
"use server"
const content = data.get("content") as string
const result = await db.insert(posts).values({
content,
userId: "user-1"
}).returning()
revalidatePath("/")
redirect("/")
}
import { revalidatePath } from 'next/cache'
async function handleSubmit(data: FormData) {
"use server"
const content = data.get("content") as string
const result = await db.insert(posts).values({
content,
userId: "user-1"
}).returning()
revalidatePath("/")
redirect("/")
}
Before we redirect the user, we tell next that the home page needs to be revalidated. We're telling next to disregard any cached data for that route and make a new request to the database to get all the posts.
Loading State
When we submit the form, nothing happens in the UI while the request is being made. There's nothing to indicate a loading/processing state and the submit button is still active so we can accidentally submit more than once. Let's fix these issues.
- src
- app
- create-post
- page.tsx
- submit-button.tsx
"use client"
import { twMerge } from "tailwind-merge"
import { useFormStatus } from "react-dom"
export default function SubmitButton() {
const { pending } = useFormStatus()
return (
<button
type="submit"
className={twMerge(
"border rounded-xl px-4 py-2 disabled",
pending && 'opacity-50 cursor-not-allowed'
)}
disabled={pending}
aria-disabled={pending}
>
Post
</button>
)
}
"use client"
import { twMerge } from "tailwind-merge"
import { useFormStatus } from "react-dom"
export default function SubmitButton() {
const { pending } = useFormStatus()
return (
<button
type="submit"
className={twMerge(
"border rounded-xl px-4 py-2 disabled",
pending && 'opacity-50 cursor-not-allowed'
)}
disabled={pending}
aria-disabled={pending}
>
Post
</button>
)
}
"use client"
import { twMerge } from "tailwind-merge"
import { useFormStatus } from "react-dom"
export default function SubmitButton() {
const { pending } = useFormStatus()
return (
<button
type="submit"
className={twMerge(
"border rounded-xl px-4 py-2 disabled",
pending && 'opacity-50 cursor-not-allowed'
)}
disabled={pending}
aria-disabled={pending}
>
Post
</button>
)
}
"use client"
import { twMerge } from "tailwind-merge"
import { useFormStatus } from "react-dom"
export default function SubmitButton() {
const { pending } = useFormStatus()
return (
<button
type="submit"
className={twMerge(
"border rounded-xl px-4 py-2 disabled",
pending && 'opacity-50 cursor-not-allowed'
)}
disabled={pending}
aria-disabled={pending}
>
Post
</button>
)
}
import SubmitButton from './submit-button'
export default function CreatePost() {
return (
<main className="text-center mt-10">
<form className="border border-neutral-500 rounded-lg px-6 py-4 flex flex-col gap-4">
<label className="w-full">
<textarea
className="bg-transparent flex-1 border-none outline-none w-full"
name="content"
placeholder="Post a thing..."
required
/>
</label>
<SubmitButton />
</form>
</main>
)
}
import SubmitButton from './submit-button'
export default function CreatePost() {
return (
<main className="text-center mt-10">
<form className="border border-neutral-500 rounded-lg px-6 py-4 flex flex-col gap-4">
<label className="w-full">
<textarea
className="bg-transparent flex-1 border-none outline-none w-full"
name="content"
placeholder="Post a thing..."
required
/>
</label>
<SubmitButton />
</form>
</main>
)
}
import SubmitButton from './submit-button'
export default function CreatePost() {
return (
<main className="text-center mt-10">
<form className="border border-neutral-500 rounded-lg px-6 py-4 flex flex-col gap-4">
<label className="w-full">
<textarea
className="bg-transparent flex-1 border-none outline-none w-full"
name="content"
placeholder="Post a thing..."
required
/>
</label>
<SubmitButton />
</form>
</main>
)
}
import SubmitButton from './submit-button'
export default function CreatePost() {
return (
<main className="text-center mt-10">
<form className="border border-neutral-500 rounded-lg px-6 py-4 flex flex-col gap-4">
<label className="w-full">
<textarea
className="bg-transparent flex-1 border-none outline-none w-full"
name="content"
placeholder="Post a thing..."
required
/>
</label>
<SubmitButton />
</form>
</main>
)
}
Now if we submit a new post. The submit button will be disabled and the opacity will be reduced to indicate that the form is being submitted. It's a very subtle change, but it's a step in the right direction for a better user experience.
Let's break down the code in the SubmitButton
component.
Submit Button
We start by adding "useClient to the top of the file. This allows us to run client-side JavaScript in the browser. More on this in the next section
"use client"
// ...
"use client"
// ...
"use client"
// ...
"use client"
// ...
Then we add the useFormStatus
hook. This hook will interact the server action and give us information about the form's status.
import { useFormStatus } from "react-dom"
export default function SubmitButton() {
const { pending } = useFormStatus()
// ...
}
import { useFormStatus } from "react-dom"
export default function SubmitButton() {
const { pending } = useFormStatus()
// ...
}
import { useFormStatus } from "react-dom"
export default function SubmitButton() {
const { pending } = useFormStatus()
// ...
}
import { useFormStatus } from "react-dom"
export default function SubmitButton() {
const { pending } = useFormStatus()
// ...
}
Finally, we use the data to adjust the button's state. React will take care of actually updating the button when pending changes.
export default function SubmitButton() {
// ...
return (
<button
type="submit"
className={twMerge(
"border rounded-xl px-4 py-2 disabled",
pending && 'opacity-50 cursor-not-allowed'
)}
disabled={pending}
aria-disabled={pending}
>
Post
</button>
)
}
export default function SubmitButton() {
// ...
return (
<button
type="submit"
className={twMerge(
"border rounded-xl px-4 py-2 disabled",
pending && 'opacity-50 cursor-not-allowed'
)}
disabled={pending}
aria-disabled={pending}
>
Post
</button>
)
}
export default function SubmitButton() {
// ...
return (
<button
type="submit"
className={twMerge(
"border rounded-xl px-4 py-2 disabled",
pending && 'opacity-50 cursor-not-allowed'
)}
disabled={pending}
aria-disabled={pending}
>
Post
</button>
)
}
export default function SubmitButton() {
// ...
return (
<button
type="submit"
className={twMerge(
"border rounded-xl px-4 py-2 disabled",
pending && 'opacity-50 cursor-not-allowed'
)}
disabled={pending}
aria-disabled={pending}
>
Post
</button>
)
}
Use Action State
In react 19, there is a new hook called useActionState
that adds even more client side state to our form while still using server actions and allowing progressive enhancement.
It's very new and we won't cover it here, but here's the link: https://react.dev/reference/react/useActionState
More About Server Actions
Docs: https://nextjs.org/docs/app/building-your-application/data-fetching/forms-and-mutations