In the next section, we'll begin building the ChatGPT integration into our application. When a user submits a question, we'll need to send that question to ChatGPT to receive a response from the AI.
While you could call ChatGPT directly from the client, this would expose our OpenAI token. Another option would be making an API route, which App Router supports. However, there's a new option.
Server actions are a new feature and fundamental part of the App Router. They provide a way for the client to communicate with the server without exposing sensitive information like API tokens. Before we use server actions in our app, let's experiment with them in a test app.
Creating a Simple To-do List App
Run the following command to create a new app, and take all the installation defaults:
pnpm dlx create-next-app@latest --use-pnpm server-actions
Once the app is created, open the page.tsx
file and remove the boilerplate code.
Next, create a todos.json
file that will act as our database. Add some sample to-dos to this file:
[
{"title": "Get Milk", "completed": false, "id": "1"},
{"title": "Get Bread", "completed": false, "id": "2"},
{"title": "Get Eggs", "completed": false, "id": "3"}
]
We'll need to create functions to interact with this database. Create a new file called todos.ts
at the top level of the src
directory. This is where we'll define a Todo
type and add functions to get and add to-dos:
export interface Todo {
completed: boolean;
}
export async function getTodos() {
const file = await fs.readFile("todos.json", "utf8");
return JSON.parse(file.toString()) as Todo[];
}
export async function addTodo(title: string) {
const todos = await getTodos();
const newTodo = {
id: Math.random().toString(36).substring(7),
title,
completed: false,
};
todos.push(newTodo);
await fs.writeFile("todos.json", JSON.stringify(todos, null, 2));
}
The getTodos
function reads the todos.json
file and returns the parsed JSON data. The addTodo
function takes a title, generates a random ID, creates a new to-do object, adds it to the list of to-dos, and writes the updated list back to the file.
Now, let's test these functions by importing them into the page.tsx
file and using them to display and add to-dos. Notice that the import comes from @todos
, which is the alias for the src
directory:
// inside page.tsx
import { getTodos } from "@todos";
Because getTodos
is an asynchronous function, we need to add the async
keyword to the Home
component. For now, we'll just display the todos in the component:
export default async function Home() {
const todos = await getTodos();
return <main className="p-5">{JSON.stringify(todos)}</main>;
}
Run the development server using npm run dev
and open the app in your browser. You should see the list of to-dos displayed on the page.
Displaying To-Dos in a Client Component
Create a new file called Todos.tsx
in the app
directory to define a client component for rendering the to-dos:
"use client";
import { Todo } from "@/todos";
export default function Todos({ todos }: { todos: Todo[] }) {
return (
<>
<h2 className="text-2xl font-bold mb-5">Todos</h2>
<ul>
{todos.map((todo) => (
<li key={todo.id} className="mb-2">
{todo.title}
</li>
))}
</ul>
</>
);
}
Update the page.tsx
file to import and use the Todos
component:
// inside page.tsx
import { Todos } from './Todos';
import { getTodos } from '@/todos';
export default async function Page() {
const todos = await getTodos();
return (
<main className="max-w-xl mx-auto mt-5">
<Todos todos={todos} />
</main>
);
}
Now the to-dos should now be displayed as a list in the browser:
Adding a Form to Create To-Dos
In order to allow users to add new to-dos, we need to add a form in the Todos
component. This will require the useState
hook to manage the input value.
// app/Todos.tsx
'use client';
import { useState } from 'react';
// below the todo list:
<form onSubmit={async (e) => {
e.preventDefault();
setNewTodo("");
}}>
<input
type="text"
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
className="border p-1 text-black"
/>
<button type="submit" className="border p-1">
Add
</button>
</form>
The form uses the newTodo
state to manage the input value and calls the addTodo
function on submit.
Now that the form is in place, we need to wire it up so that to-dos can be added.
Inside of the page.tsx
file, import the addTodo
function from todos
an add is as a prop on the Todos
component:
// inside page.tsx
import { getTodos, addTodo } from "@/todos";
// inside the return
<Todos todos={todos} addTodo={addTodo} />
Now over in the Todos.tsx
file, we need to add the addTodo
prop to the component and wire it up to the form. It is typed as a function that takes a title and because it's async it will return a Promise to void
:
export default function Todos({
todos,
addTodo,
}: {
todos: Todo[];
addTodo: (title: string) => Promise<void>;
})
...
Now when we try to submit a new to-do item, we get an error because functions cannot be passed directly to client components:
The 'use server'
directive indicates that the function should be executed on the server when called from the client. We can add it to the addTodo
function directly:
export async function addTodo(title: string) {
"use server";
const todos = await getTodos();
...
Now, when you submit the form, the addTodo
function will be invoked on the server without error. The submit button is hit, a POST request is made, and Next.js takes care of the rest for us.
However, the item won't be displayed until a refresh.
Revalidating Data
We need to revalidate the data to fetch the updated list of to-do items. Inside of todo.ts
, import revalidatePath
from next/cache
:
// src/todos.ts
import { revalidatePath } from 'next/cache';
The revalidatePath
function tells Next.js to invalidate the data at the specified path and refetch it on the next request. We'll call it with the root /
path after adding a new to-do:
// inside of the addTodo function
...
await fs.writeFile("todos.json", JSON.stringify(todos, null, 2));
revalidatePath("/");
After this change, a new to-do item will be added to the list without needing to refresh the page:
This works, but it could be better.
Using Unstable Cache
Using revalidatePath
means that the addTodo
function needs to know what parts of the app are maintaining and showing the list of to-dos.
To avoid tightly coupling the addTodo
function with specific routes, we can use the unstable_cache
function from Next.js. This function allows us to cache the result of a function and assign a tag to it. We can then invalidate the cache by calling revalidateTag
with the same tag.
Instead of using revalidatePath
, we'll instead use unstable_cache
and revalidateTag
from next/cache
:
// src/todos.ts
import { unstable_cache, revalidateTag } from 'next/cache';
We'll then create a getTodos
constant using the unstable_cache
function to wrap the getTodosFromFile
function and assign tags to the cache:
export const getTodos = unstable_cache(
getTodosFromFile,
["todo-list"],
{
tags: ["todos"]
}
);
At this point we actually have stronger caching that we did before. The entire todo list has been cached by Next, so we need to use the revalidateTag
function to invalidate the cache for the todos
tag:
// inside of the addTodo function
await fs.writeFile("todos.json", JSON.stringify(todos, null, 2));
revalidateTag("todos");
With this change, the list will reload and the to-do system only needs to know that there is data tagged as todos
instead of knowing about application routes.
Fetching Data on the Server
To display the total count of to-dos, we can fetch the data on the server and pass it as a prop to the client component.
Inside of the todos.ts
file, add a new function called getTodoCount
that fetches the to-dos and returns the count. We'll also use the "use server"
directive to specify that this function should run on the server:
// inside todos.ts
"use server";
// ...rest of file as before...
export async function getTodoCount() {
const todos = await getTodos();
return todos.length;
}
Now in the Todos.tsx
component file, we'll import the getTodoCount
function and use a useEffect
to track the count state:
// inside Todos.tsx
const [todoCount, setTodoCount] = useState(0);
useEffect(() => {
getTodoCount().then(setTodoCount);
}, []);
Finally, we'll use the todoCount
in the component to display the total number of to-dos:
// inside the Todos component:
<h2 className="text-2xl font-bold mb-5">
Todos ({todoCount})
</h2>
Now the count will display, but it starts at 0 then flips to 6 because the Todo
component is a client component:
Comparing Server Actions to API Routes
Looking in the Network tab of DevTools, notice that the request method to fetch the data is a POST
, which means we can't cache the request.
Making GET
requests to the server using server actions will always result in POST
requests.
If you were to use an API route, you get to control the verb and could use GET
to fetch the data and have it stay as a GET
. API routes also allow you to control the format of the request, which you can't do with server actions.
Fetching the data on the server avoids the need for an additional request from the client.
In order to fix the extra request for the to-do count, we can get the count and pass it as a prop instead of making the call from the component.
// inside page.tsx
import { getTodos, addTodo, getTodoCount } from "@/todos";
export default async function Home() {
const todos = await getTodos();
const todoCount = await getTodoCount();
return (
<main className="max-w-xl mx-auto mt-5">
<Todos todos={todos} addTodo={addTodo} todoCount={todoCount} />
</main>
);
}
// inside Todos.tsx
export default function Todos({
todos,
addTodo,
todoCount
})
Now the count will display without the flip from 0 to the current count, and adding items works as expected:
Wrapping Up
We looked at several different ways to fetch data and revalidate it in a Next.js application. Server actions are great for communicating between the client and server securely, but they do have some trade-offs.
For fetching data, using server-side rendering and passing the data as props may often a better choice than using server actions.
To learn more, check out the Form Management with Next.js App Router tutorial that goes deeper into some of what we covered here.