ProNextJS
    Loading
    lesson

    Intro to Server Actions

    Jack HerringtonJack Herrington

    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.

    the todos json is displayed

    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:

    to-do items are displayed

    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.

    the form is displayed

    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:

    error message

    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:

    a new todo appears

    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:

    the count initially shows as 0

    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:

    the to-do count

    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.

    Transcript