Can you use Next.js server actions to build a full stack app without APIs

Can you use Next.js server actions to build a full stack app without APIs

Published on:May 6, 2023

By[email protected]in
Blog
Web development

Intro

The Vercel team recently released Next.js version 13.4. It’s a game-changing release for several reasons.

  • The App router is now stable. At the core of Next.js is a file system-based router with 0 configurations. It’s been completely rebuilt to support new React features.
  • Turbopack, the successor to Webpack, is in Beta. Webpack was known for being hard to scale on large projects. Turbopack takes the lessons learned from Webpack and aims to solve that.
  • Server Actions are in Alpha.

Personally, I’m most excited about the Server Actions. I’ve been experimenting with the Next.js app directory for several months now, and the thing that always kept me from using it in production is the lack of a consistent way to:

a) Mutate data in the database.

b) Sync client and server.

I had created a workflow that utilizes React Query to sync server and client mutations using the pages directory, but now Next.js has a first-party solution that simplifies concepts like query invalidation.

Simplier code = fewer bugs, fewer bugs = better products, better products = happier customers.

In this article, I will explore some possibilities for the Next.js 13 app directory, server actions and exposing endpoints.

Prerequisites

  • Web development knowledge.
  • Basic familiarity with database ORMs.

The end result

I made a simple app that lets you create users and stores them in a Postgres database.

You can grab the source code in this repository: https://github.com/railaru/nextjs-postgres-starter-prisma

nextjs-server-actions-crud

Interestingly, this app doesn’t use REST or Grapl QL APIs to fetch the data, yet I’m using React’s JSX syntax.

Here’s the code I’ve used to get the users:

export default async function Table() {
  const users = await prisma.users.findMany();

  return (
    <Pane>
      <h2 className="text-xl font-semibold">Users</h2>
      <div className="w-full mt-4 overflow-scroll text-sm text-left text-gray-500 max-w-[1424px]">
        <div className="space-y-8 divide-y divide-gray-900/5">
          {users.map((user, index) => (
            <UserBlock user={user} key={index} />
          ))}
        </div>
      </div>
    </Pane>
  );
}

app/users/[id]/_actions.ts

In the code, you can see a variable users that is connected directly to a database ORM, in this case, Prisma. In theory, you can write SQL queries directly inside a React component if you want to do.

It’s possible through React.js server components, essentially Node.js code, with a better templating language.

A few years ago, my instinctual reaction to this would be:

How is this possible? Is this safe? Am I seeing a JavaScript loop iterating items straight from a database? I would think that, surely, there has to be a more legit way of interacting with a backend service.

Funny enough, many other people had this reaction.

how-is-db-calls-in-react-components-legal

But now, the same Next.js and React.js contributors have developed features to make database calls in React a viable choice in some cases.

dan-abramov-RSC-discussion

Where do the RSC fit? Finding the balance between client and server

More code doesn’t equate to better software.

To understand the importance of React Server Components and Next.js client actions, let’s first have a look at how a typical web app is written today.

In a typical web app with a backend and a frontend service, several layers of technologies are involved in displaying data from the database to the user’s device.

On the server side (backend):

Database layer: The database layer is responsible for storing, retrieving, and managing data. This layer typically includes a database such as MySQL, PostgreSQL, or MongoDB.

ORM layer: provides an abstraction of the database through objects and classes. It maps the relational database tables and columns to objects and their properties and allows developers to interact with the database using high-level objects instead of SQL queries.

Business logic layer: The business logic layer contains the application’s core logic and rules. This layer is responsible for implementing business rules, performing calculations, and making decisions based on data retrieved from the database.

API endpoint layer: Makes the data available to the frontend. It’s responsible for exposing and documenting the endpoints so the frontend service can display the data to the user.

On the client-side side (frontend):

Presentation layer: renders the user interface and handles user input. This layer typically includes HTML, CSS, and JavaScript code in the user’s browser. The presentation layer communicates with the backend server through API calls and handles data formatting and display.

Application logic layer: contains the client-side code that implements the application’s logic and rules. This layer is responsible for handling user input, making decisions based on data received from the server, and performing calculations or transformations on data.

Data access layer: handles client-side data caching, client-side validation, and data transformation before sending data to the server or after receiving data from the server.

Interface layer: handles syncing data types from the backend to the frontend. It could be written manually with TypeScript interfaces or ideally automated with technologies like tRPC or Swagger documentation converted to TypeScript interfaces with tools like swagger-typescript-api. Both solutions add development overhead when updating and maintaining types.

Separate backend and frontend architecture introduces several problems:

a) The types are declared on the front and back end.

b) Slower feature development because only a part of the context is known by other team members.

c) API layer logic is written both on the frontend and backend.

APIs are an excellent tool for accessing third-party data providers or integrating services that are not part of your core system. However, writing a REST API to build a feature with data and authorization from the same core system can potentially add a lot of complexity for very little return.

Next.js server components and actions solve that issue by tightly coupling the backend and frontend without losing the ability to build high-quality UIs quickly.

In the illustration below, you can see the two architectures compared.

separate reactjs php vs nextjs architeture

Deja vu to the PHP world

If you’re familiar with server-side technologies like PHP, you might be reading this and thinking to yourself:

Wait, weren’t these problems solved back in the 1990s? How is this new in any way? Tools like PHP, by default, allow developers to write code that runs securely on the server.


This tight coupling of client and server was one of the founding concepts of Next.js. It was marked in the Vercel CEO’s blog post from 2016.

Screenshot 2023-05-06 at 19.07.26

The challenge with purely server-side solutions like PHP is that although they’re great at writing code on the server, especially with frameworks like Symfony or Laravel, these technologies ignore what has happened in the frontend part of the web for the last decade or so.

Next.js server actions and React Server Components bridge this gap. On the one hand, you have a simple way to handle simple stuff like getting and updating data on the page.

On the other hand, if you need a more complex UI for your app that involves dialogs, modals, lazy loading and complex forms with client-side validation, you can progressively enhance your app to support it through client-side React components.

Getting the data without APIs is nice, but how do I update it?

If you’re familiar with Remix.js, you’ve probably heard about data writes. Next.js server actions are a very similar concept.

With Next.js server actions, you can place your data mutation logic inside your server component or create a separate file called _actions.ts and add the “user server” prefix.

I picked the second option so I could import these functions to several components:

"use server";

import prisma from "@/lib/prisma";
import { revalidateTag } from "next/cache";

export async function remove(formData: FormData) {
  const id = Number(formData.get("id"));

  await prisma.users.delete({
    where: {
      id,
    },
  });

  revalidateTag("users");
}

export async function update(formData: FormData) {
  const id = Number(formData.get("id"));

  await prisma.users.update({
    where: {
      id,
    },
    data: {
      name: formData.get("name") as string,
      email: formData.get("email") as string,
    },
  });

  revalidateTag("users");
}

export async function create(formData: FormData) {
  await prisma.users.create({
    data: {
      name: formData.get("name") as string,
      email: formData.get("email") as string,
      image: "https://robohash.org/example.png",
    },
  });

  revalidateTag("users");
}

app/users/[id]/_actions.ts

In the code snippet above, I’m doing two things:

1. I’m using Prisma to update records in my PostgreSQL.

2. I’m clearing the cache for the tag “users”.

And this is how I connect the UI to these actions.

<form action={update} className="mt-4 space-y-4">
  <InputGroup
    label="Name:"
    inputProps={{
      name: "name",
      id: "name",
      placeholder: "Enter your name",
      defaultValue: user.name,
    }}
  />
  <InputGroup
    label="Email:"
    inputProps={{
      name: "email",
      id: "email",
      placeholder: "Enter your email address",
      defaultValue: user.email,
    }}
  />
  <input type="hidden" name="id" value={user?.id} />
  <Button type="submit">Submit</Button>
</form>

components/UserBlock.tsx

The connection between the frontend and backend is done through the HTML form action.

It’s all you need to update and sync your UI and database. No need for tools like:

  • Redux Thunk
  • React Query
  • Axios

And as a bonus, we get complete type safety from the database to the UI using Prisma Client.

So to answer the original question: Can you build a full-stack React app without APIs?

As always, the answer depends on what you’re building.

The upside of using server actions instead of REST APIs is that fewer layers stand between your database and your UI. For simple functionality like displaying and updating records, you can have better performance and write less code to achieve the same result.

The downside is that you must use HTML forms to perform mutations. Building more complex interactions can become as tedious as working with HTML and PHP solutions like Twig.

The good news, you don’t have to stick to one approach. You can have the best of both worlds. Next.js makes exposing the endpoints to the client side easy for building more interactive UIs.

export async function GET() {
  const users = await prisma.users.findMany();

  return NextResponse.json({ users });
}

app/users/api/route.ts

This simple piece of code results in a REST endpoint. You can see the result in Postman.

postman-nextjs-api

Using this approach, you can handle interactive parts of your app on the client-side and handle-less interactive parts through server components and actions.

Leveraging new React Hooks

Another way of overcoming server actions’ limitations is using the new React Hooks like useTransition.

react-usetransition-hooks-for-nextjs-server-actions

You can read more about it in the Next.js documentation.

Conclusion

Next.js server actions have pushed web development towards writing fewer APIs and returning to good, old and tested ideas. As developers, it forces us to think about more optimal communication methods between the back and frontend while building delightful UIs.

Learning web fundamentals is still the key to not getting lost in the information ocean. Focus your learning time on understanding the HTTP protocol, client and server interaction, status codes, and how HTML works. This will make it easy to adapt to changes in high-level frameworks like Next.js

Further reading

How to do server actions in Next.js: https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions

How to expose APIs in Next.js: https://nextjs.org/docs/app/building-your-application/routing/router-handlers

Remix.js data writes: https://remix.run/docs/en/main/guides/data-writes

Other blog posts