Server Actions

Time to start creating posts. This means POSTing data to the server so we can store it in the database.

step 1:

Make sure your create post page has some sort of form in it that allows you to create a post.

At a bare minimum it should have a textarea input and a submit button.

create-post/page.tsx
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>
)
}
create-post/page.tsx
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>
)
}
create-post/page.tsx
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>
)
}
create-post/page.tsx
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>
)
}

Make sure you give any form elements a meaningful name attribute.

<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:

Method 1

Have the form submit a POST request to some endpoint using the form's action and method.

This method doesn't utilize client side JS at all, which has it's benefits, however, it has less potential for a good User Experience.

create-post/page.tsx
export default function CreatePost() {
return (
<main className="text-center mt-10">
<form action="/create-post" method="POST">
...
</form>
</main>
)
}
create-post/page.tsx
export default function CreatePost() {
return (
<main className="text-center mt-10">
<form action="/create-post" method="POST">
...
</form>
</main>
)
}
create-post/page.tsx
export default function CreatePost() {
return (
<main className="text-center mt-10">
<form action="/create-post" method="POST">
...
</form>
</main>
)
}
create-post/page.tsx
export default function CreatePost() {
return (
<main className="text-center mt-10">
<form action="/create-post" method="POST">
...
</form>
</main>
)
}
Method 2

Add an event listener to the form, then submit the form data to the backend using fetch.

This method relies on client side JS and can have a much better User Experience than the previous method. But only works if the user has JS enabled.

create-post/page.tsx
"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>
)
}
create-post/page.tsx
"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>
)
}
create-post/page.tsx
"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>
)
}
create-post/page.tsx
"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.

The Goal

Post data from the client and store it in the database.

Next.js Server Actions

In Next.js, we have the ability to use something called server actions

step 2:

Update the create post page to use a server action.

create-post/page.tsx
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>
)
}
create-post/page.tsx
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>
)
}
create-post/page.tsx
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>
)
}
create-post/page.tsx
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>
)
}
step 3:

Enable Server Actions in your next.config.js file:

next.config.js
module.exports = {
experimental: {
serverActions: true,
},
}
next.config.js
module.exports = {
experimental: {
serverActions: true,
},
}
next.config.js
module.exports = {
experimental: {
serverActions: true,
},
}
next.config.js
module.exports = {
experimental: {
serverActions: true,
},
}

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

step 4:

Create a new post in the database from the submitted form data.

hard code the user id for now.

create-post/page.tsx
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)
}
create-post/page.tsx
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)
}
create-post/page.tsx
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)
}
create-post/page.tsx
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.

step 4:

Import the redirect function from next/navigation

Update the server action to redirect the user to the home page after creating a new post.

create-post/page.tsx
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("/")
}
create-post/page.tsx
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("/")
}
create-post/page.tsx
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("/")
}
create-post/page.tsx
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

step 5:

Import the revalidatePath function from next/cache

Update the server action to revalidate the home page before redirecting the user

create-post/page.tsx
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("/")
}
create-post/page.tsx
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("/")
}
create-post/page.tsx
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("/")
}
create-post/page.tsx
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.

step 6:

Create a new submit-button file.

  • src
    • app
    • create-post
      • page.tsx
      • submit-button.tsx
step 7:

Add the following code to the submit button file.

submit-button.tsx
"use client"

import { twMerge } from "tailwind-merge"
import { experimental_useFormStatus as 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>
)
}
submit-button.tsx
"use client"

import { twMerge } from "tailwind-merge"
import { experimental_useFormStatus as 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>
)
}
submit-button.tsx
"use client"

import { twMerge } from "tailwind-merge"
import { experimental_useFormStatus as 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>
)
}
submit-button.tsx
"use client"

import { twMerge } from "tailwind-merge"
import { experimental_useFormStatus as 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>
)
}
step 8:

In the create post page, import the submit button and replace the form's button with the new SubmitButton

create-post/page.tsx
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>
)
}
create-post/page.tsx
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>
)
}
create-post/page.tsx
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>
)
}
create-post/page.tsx
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

submit-button.tsx
"use client"

// ...
submit-button.tsx
"use client"

// ...
submit-button.tsx
"use client"

// ...
submit-button.tsx
"use client"

// ...

Then we add the useFormStatus hook. This hook will interact the server action and give us information about the form's status.

submit-button.tsx
import {
experimental_useFormStatus as useFormStatus
} from "react-dom"

export default function SubmitButton() {
const { pending } = useFormStatus()
// ...
}
submit-button.tsx
import {
experimental_useFormStatus as useFormStatus
} from "react-dom"

export default function SubmitButton() {
const { pending } = useFormStatus()
// ...
}
submit-button.tsx
import {
experimental_useFormStatus as useFormStatus
} from "react-dom"

export default function SubmitButton() {
const { pending } = useFormStatus()
// ...
}
submit-button.tsx
import {
experimental_useFormStatus as 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.

submit-button.tsx
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>
)
}
submit-button.tsx
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>
)
}
submit-button.tsx
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>
)
}
submit-button.tsx
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>
)
}

More About Server Actions

Docs: https://nextjs.org/docs/app/building-your-application/data-fetching/forms-and-mutations