ProNextJS
    Loading
    lesson

    Building with Local Server Actions in Next.js

    Jack HerringtonJack Herrington

    The server action variant of the local architecture is perfect for small teams, startups, or internal admin tools where simplicity and efficiency are key.

    Project Setup

    We're starting with a Next.js project set up in the apps/local-sa directory. You can launch it using:

    pnpm dev:local-sa 
    

    Understanding the Architecture

    flow chart

    The architecture is simple– here's the flow:

    1. Data Fetching (RSC): The app uses a React Server Component (RSC) on the homepage to fetch to-dos from the todos library.
    2. Client-Side Rendering: The RSC passes the fetched data to the client-side component for rendering.
    3. Server Actions: The client utilizes server actions for any updates (adding, marking complete, deleting) to the to-dos.
    4. Revalidation: After a server action completes, we revalidate the / route, triggering a refresh and reflecting the changes in the UI.

    Code Walkthrough

    Let's break down the code.

    page.tsx

    The important parts are in local-sa/src/app/page.tsx where the to-do list operations are imported at the top of the page. We get the user's session, priorities, and to-dos. The to-dos are fetched only when the user is logged in.

    import { auth } from "@/auth";
    import { revalidatePath } from "next/cache";
    
    import {
      Todo,
      getTodos,
      PRIORITIES,
      addTodo,
      updateTodoCompletion,
      deleteTodo,
    } from "@repo/todos";
    
    import AuthButton from "@/components/AuthButton.server";
    
    import TodoList from "./TodoList";
    
    export default async function Home() {
      const session = await auth();
    
      const priorities: string[] = PRIORITIES;
    
      const todos: Todo[] = session?.user?.id ? getTodos(session?.user?.id) : [];
    
      async function addTodoAction(
        title: string,
        priority: string,
        completed: boolean = false
      ) {
        "use server";
        const session = await auth();
        if (!session?.user?.id) throw new Error("User not authenticated");
        addTodo(session?.user?.id, {
          id: `${session?.user?.id}-${Date.now()}`,
          title,
          priority,
          completed,
        });
        revalidatePath("/");
      }
    
      async function updateTodoCompletionAction(
        todoId: string,
        completed: boolean
      ) {
        "use server";
        const session = await auth();
        if (!session?.user?.id) throw new Error("User not authenticated");
        updateTodoCompletion(session?.user?.id, todoId, completed);
        revalidatePath("/");
      }
    
      async function deleteTodoAction(todoId: string) {
        "use server";
        const session = await auth();
        if (!session?.user?.id) throw new Error("User not authenticated");
        deleteTodo(session?.user?.id, todoId);
        revalidatePath("/");
      }
    
      return (
        <main>
          <AuthButton />
          {session?.user && (
            <TodoList
              todos={todos}
              priorities={priorities}
              addTodoAction={addTodoAction}
              updateTodoCompletionAction={updateTodoCompletionAction}
              deleteTodoAction={deleteTodoAction}
            />
          )}
        </main>
      );
    }
    

    Server actions addTodoAction, deleteTodoAction, updateTodoCompletionAction are defined to manage to-do modifications.

    TodoList.tsx

    The TodoList.tsx Client Component is at apps/local-sa/src/apps/TodoList.tsx.

    Our client component receives the fetched to-do data and the server actions for user interactions.

    It manages state for new to-do input and uses event handlers to interact with those server actions:

    "use client";
    import { useState } from "react";
    import {
      Select,
      SelectContent,
      SelectItem,
      SelectTrigger,
      SelectValue,
    } from "@/components/ui/select";
    import { Input } from "@/components/ui/input";
    import { Checkbox } from "@/components/ui/checkbox";
    
    import { Todo } from "@repo/todos";
    import { Button } from "@/components/ui/button";
    
    export default function TodoList({
      priorities,
      todos,
      addTodoAction,
      updateTodoCompletionAction,
      deleteTodoAction,
    }: {
      priorities: string[];
      todos: Todo[];
      addTodoAction: (title: string, priority: string, completed?: boolean) => void;
      updateTodoCompletionAction: (todoId: string, completed: boolean) => void;
      deleteTodoAction: (todoId: string) => void;
    }) {
      const [priority, setPriority] = useState<string>(priorities[0]);
      const [title, setTitle] = useState<string>("");
    
      const onSubmit = async () => {
        await addTodoAction(title, priority);
      };
    
      const onSetCompleted = async (id: string, completed: boolean) => {
        await updateTodoCompletionAction(id, completed);
      };
    
      const onDelete = async (id: string) => {
        await deleteTodoAction(id);
      };
    
      return (
        <div className="mt-5">
          {todos && (
            <>
              <ul>
                {todos?.map((todo) => (
                  <li key={todo.id} className="flex gap-2 items-center mb-3">
                    <Checkbox
                      checked={todo.completed}
                      onClick={() => onSetCompleted(todo.id, !todo.completed)}
                    />
                    <div className="flex-grow">{todo.title}</div>
                    <Button variant="destructive" onClick={() => onDelete(todo.id)}>
                      Delete
                    </Button>
                  </li>
                ))}
              </ul>
            </>
          )}
          <div className="flex gap-2">
            <Select value={priority} onValueChange={(v) => setPriority(v)}>
              <SelectTrigger className="w-[180px]">
                <SelectValue placeholder="Priority" />
              </SelectTrigger>
              <SelectContent>
                {priorities.map((priority) => (
                  <SelectItem value={priority} key={priority}>
                    {priority}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
            <Input
              placeholder="Todo"
              value={title}
              onChange={(evt) => setTitle(evt.target.value)}
            />
            <Button onClick={onSubmit}>Submit</Button>
          </div>
        </div>
      );
    }
    

    The beauty of this setup is that each time a server action modifies data, in the page.tsx component it revalidates the / path.

    This has Next.js re-run the server component associated with this route, then the updated data is sent to the client, seamlessly updating the UI.

    Why This Architecture Works

    This server action-based local architecture excels for its simplicity. It's easy to understand, has minimal boilerplate, and is performant way to build applications at this scale.

    Transcript

    Let's take a look at the server action variant of the local architecture. This is located in the app's local SA directory. You can launch it using pmpm dev colon local SA. The architecture in this case could not be simpler. We have an app called local SA.

    It makes requests to the to-dos library to get the list of to-dos for the current user. In the RSCs, passes that on to the client. The client then uses server actions to make mutations to the to-dos and we revalidate the path in order to make updates to the page. This is pretty much the canonical Next.js App Writer to-do list application. This architecture is really good for small startups just getting started or POC applications, or for admin applications inside of larger companies.

    Let's dig into the code a little bit. The important parts are over in the page, where we import all the to-do list operations at the top of the page. Then in the homepage, we get the session to know who we are, we get the priorities and as well as the to-dos. And when we get the to-dos, we only do that when we're logged in and we send along the user ID to get the to-dos. Then we create a set of server actions including add to-do, update to-do completion, and the delete to-do action.

    We render the off button that allows people to sign in and sign out. If you are signed in you get your to-do list. That's a client component and we send it the to-dos and the priorities as well as all of the server actions. Now of course those server actions could be extracted from this component put into a different file with you server at the top, completely up to you. If we look over at the client component, we take our data, our priorities, and our to-dos, as well as all those server actions.

    We have some state that manages the new to-do, including the priority and the title of the to-do. We have some event handlers for the various pieces of interactivity including the delete button, the completion check, and the adding of a new to-do that simply just send off data to those server actions. And then we have the formatting JSX that puts it into a table. When I make a mutation, the UI updates because if we look back over here at our code for our server actions, we revalidate the path of slash, and that's going to force a router refresh on the current route. And that's a high-level overview of how the local server action variant works.

    It's just very simple. And honestly, this is pretty much kind of how the Next.js App Writer is meant to be built at this kind of scale.