DEV Community

Cover image for Is Next.js 13 + RSC a Good Choice? I Built an App Without Client-Side Javascript to Find Out
ymc9 for ZenStack

Posted on • Originally published at zenstack.dev

Is Next.js 13 + RSC a Good Choice? I Built an App Without Client-Side Javascript to Find Out

Next.js 13 ignited the first wave of attention to React Server Components (RSC) around the end of last year. Over time, other frameworks, like Remix and RedwoodJS, have also started to put RSC into their future road maps. However, the entire "moving computation to the server-side" direction of React/Next.js has been highly controversial from the very beginning.

With RSC and the (still experimental) server actions, it should be possible to build a full-stack app without any client-side Javascript code. How well does it really work? I set out to gain first-hand experience by rebuilding my favorite blogging app. Yes, it's a very simple app, but it could serve as a tangible way to understand the new patterns. At least part of it.

Requirements

If you've read some of my previous posts, you probably know the app's requirements. Here's a quick recap for the new readers:

  1. Email/password-based sign-in/signup.
  2. Users can create posts for themselves.
  3. Post owners can update/publish/unpublish/delete their own posts.
  4. Users cannot make changes to posts that do not belong to them.
  5. Published posts can be viewed by all logged-in users.

The app will be built with the following stack:

  • Next.js 13 "app" routes, using RSC wherever possible
  • NextAuth for authentication
  • Prisma + ZenStack for data access and authorization
  • SQLite for storage

Scaffolding

My favorite way of creating a new Next.js app has always been using create-t3-app.

npm create t3-app@latest
Enter fullscreen mode Exit fullscreen mode

The following options are used:

  • TypeScript
  • App router
  • TailwindCSS
  • Prisma
  • NextAuth

The created boilerplate code uses Discord provider for authentication. I've changed it to use credentials instead (using bcryptjs for hashing passwords):

// src/server/auth.ts

import { compare } from "bcryptjs";

export const authOptions: NextAuthOptions = {
  session: {
    strategy: "jwt",
  },
  callbacks: {
    session({ session, token }) {
      if (session.user) {
        session.user.id = token.sub!;
      }
      return session;
    },
  },
  adapter: PrismaAdapter(db),
  providers: [
    CredentialsProvider({
      credentials: {
        email: { type: "email" },
        password: { type: "password" },
      },
      authorize,
    }),
  ],
};

async function authorize(
  credentials: Record<"email" | "password", string> | undefined,
) {
  if (!credentials?.email) {
    throw new Error('"email" is required in credentials');
  }

  if (!credentials?.password) {
    throw new Error('"password" is required in credentials');
  }

  const maybeUser = await db.user.findFirst({
    where: { email: credentials.email },
    select: { id: true, email: true, password: true },
  });

  if (!maybeUser) {
    return null;
  }

  if (!await compare(credentials.password, maybeUser.password)) {
    return null;
  }

  return { id: maybeUser.id, email: maybeUser.email };
}
Enter fullscreen mode Exit fullscreen mode

Implementing the Requirements

Signup and Sign-in

The first step is to implement the signup and sign-in UI. Building signup UI is easy thanks to the Server Actions feature. But to use that, we have to turn on that experimental flag:

// next.config.mjs

const config = {
  experimental: {
    serverActions: true
  },
};
Enter fullscreen mode Exit fullscreen mode

Then, building the signup UI is straightforward (I've removed all styling for brevity):

// src/app/signup/page.tsx

import { hashSync } from "bcryptjs";
import type { NextPage } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { z } from "zod";
import { db } from "~/server/db";

const signupSchema = z.object({
  email: z.string().email(),
  password: z.string(),
});

const Signup: NextPage = () => {
  async function signup(formData: FormData) {
    "use server";

    const parsed = signupSchema.parse(Object.fromEntries(formData));

    try {
      const user = await db.user.create({
        data: {
          email: parsed.email,
          password: hashSync(parsed.password),
        },
      });
      console.log("User created:", user);
    } catch (err: any) {
      console.error(err);
      if (err.info?.prisma && err.info?.code === "P2002") {
        return { message: "User already exists" };
      } else {
        return { message: "An unknown error occurred" };
      }
    }

    redirect("/");
  }

  return (
    <div>
      <h1>Sign up</h1>
      <form action={signup}>
        <div>
          <label htmlFor="email">
            Email
          </label>
          <input name="email" type="email" />
        </div>
        <div>
          <label htmlFor="password">
            Password
          </label>
          <input name="password" type="password" />
        </div>
        <input type="submit" value="Create account" />
      </form>
    </div>
  );
};

export default Signup;
Enter fullscreen mode Exit fullscreen mode

A few quick notes:

  1. The use server directive marks an async function as a server action. You can call it from client code (here as a form action), and the input and output data will be automatically marshaled across the network boundary. It's pretty neat that you don't need to define any API for handling forms anymore.
  2. Since the server action is exposed to the public network, even though it resides inside the component and serves only this component, we still need to validate its input (using "zod" here).

The sign-in part is handled by NextAuth and doesn't involve server components or actions. I'm skipping it now since it's not directly related to the goal of this post, but you can find the implementation in the repository shared at the end of the post.

Post management

The post-management part was built by combining the following things:

  1. Server components for loading posts
  2. Server actions for mutation
  3. ZenStack for automatic enforcement of access control. ZenStack is a toolkit that extends Prisma ORM to allow you to model access policies and data schema in one place.

The first step is to initialize the project for ZenStack:

npx zenstack@latest init
Enter fullscreen mode Exit fullscreen mode

It installs a few dependencies and also copies over the prisma/schema.prisma file to schema.zmodel. The ZModel file is a superset of Prisma schema, containing both the data model and access policies. The following code shows how access control is modeled inside it:

// schema.zmodel

model Post {
    id Int @id @default(autoincrement())
    name String
    createdAt DateTime @default(now())
    updatedAt DateTime @updatedAt

    createdBy User @relation(fields: [createdById], references: [id])
    createdById String

    published Boolean @default(false)

    @@index([name])

    // πŸ” author has full access
    @@allow('all', auth() == createdBy)

    // πŸ” logged-in users can view published posts
    @@allow('read', auth() != null && published)
}
Enter fullscreen mode Exit fullscreen mode

Running npx zenstack generate will generate code that supports ZenStack's runtime policy check.
With this in place, we can build post-management features into the homepage without much effort. Again, styling is abbreviated.

// src/app/page.tsx

import { enhance } from "@zenstackhq/runtime";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { getServerAuthSession } from "~/server/auth";
import { db } from "~/server/db";

// Get an enhanced PrismaClient instance with access policy enforcement
async function getEnhancedDb() {
  const session = await getServerAuthSession();
  return enhance(db, {
    user: session?.user ? { id: session.user.id } : undefined,
  });
}

const createSchema = z.object({ name: z.string() });
async function create(formData: FormData) {
  "use server";

  const session = await getServerAuthSession();
  if (!session) {
    return { error: "not logged in" };
  }

  const parsed = createSchema.parse(Object.fromEntries(formData));
  const db = await getEnhancedDb();
  await db.post.create({
    data: {
      name: parsed.name,
      createdBy: { connect: { id: session?.user.id } },
    },
  });
  revalidatePath("/");
}

const togglePublishedSchema = z.object({ id: z.coerce.number() });
async function togglePublished(formData: FormData) {
  "use server";

  const parsed = togglePublishedSchema.parse(Object.fromEntries(formData));
  const db = await getEnhancedDb();
  const curr = await db.post.findUnique({ where: { id: parsed.id } });
  if (!curr) {
    return { error: "post not found" };
  }
  await db.post.update({
    where: { id: parsed.id },
    data: { published: !curr.published },
  });

  revalidatePath("/");
}

const deleteSchema = z.object({ id: z.coerce.number() });
async function deletePost(formData: FormData) {
  "use server";

  const parsed = deleteSchema.parse(Object.fromEntries(formData));
  const db = await getEnhancedDb();
  await db.post.delete({
    where: { id: parsed.id },
  });
  revalidatePath("/");
}

const Posts = async () => {
  const db = await getEnhancedDb();
  // only posts readable to the current user will be loaded
  const posts = await db.post.findMany({
    include: { createdBy: true },
    orderBy: { createdAt: "desc" },
  });

  return (
    <div>
      <form action={create}>
        <input type="text" name="name" />
        <input type="submit" value="+ Create Post" />
      </form>

      <ul>
        {posts?.map((post) => (
          <li key={post.id}>
            <p>
              {post.name} by {post.createdBy.email}
            </p>
            <div>
              <form action={togglePublished}>
                <input type="hidden" name="id" value={post.id} />
                <input type="submit" value={post.published ? "Unpublish": "Publish"} />
              </form>
              <form action={deletePost}>
                <input type="hidden" name="id" value={post.id} />
                <input type="submit" value="Delete" />
              </form>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Result App

A few quick notes:

  1. Posts is a server component. Its code executes on the server side and can directly access server-side resources like the database.
  2. The enhance API (from ZenStack) creates a wrapped PrismaClient that automatically enforces the access policies we saw previously (with respect to the current user's identity). Without any filtering, the db.post.findMany call only returns posts readable to the current user. Similarly, mutations will be rejected if the current user is not allowed to do so.
  3. After mutation, you need to call revalidatePath or revalidateTag to invalidate cached data and trigger refetching.

Takeaways

Here are my thoughts about the benefits and challenges from a retrospection on how I built this toy app (compared to how it was implemented with the traditional "pages" route).

The Gains

  • RSC allows you to load data more naturally without requiring a separate function like getServerSideProps. The data-fetching code sits right inside the component.
  • Data fetching at the component level makes the loading logic better colocated where it's used.
  • There's no need to use data fetching libraries like SWR or TanStack Query anymore.
  • Server actions eliminate the need to define mutation APIs. Client code calls directly to the server side, and the framework handles all the RPC details.
  • The combination of RSC + Server Actions + ZenStack reduces the boilerplate code to a minimum.

The Challenges

  • Even after reading through the documentation multiple times, I still needed to carefully think about the boundary between client and server components and how to organize them properly.
  • The use client and use server directives are not too esthetic, and I believe they'll be littered in many places in a complex app.
  • Developers are fully responsible for revalidating routes when necessary. It's okay for this simple app, but it can be tricky for a complex one.

Wrap Up

Thank you for spending time on this article, and I hope you enjoyed the reading. Evaluating React Server Components and Next.js 13 is a broad topic, and whether they're the right way to go is still very controversial. I hope this post helps give a quick glimpse into the experiences of using them.

You can find the source code of the completed app here: https://github.com/zenstackhq/sample-blog-nextjs-rsc.


We're building ZenStack, a toolkit that supercharges Prisma ORM with a powerful access control layer and unleashes its full potential for full-stack development. If you enjoy the reading and feel the project interesting, please help star it so that more people can find it!

Top comments (4)

Collapse
 
vladern profile image
vladyslav kuchmenko

Really interesting approach.

Collapse
 
dsaga profile image
Dusan Petkovic

It was pretty fast in production mode when I used it for a project, it was hosted on a vps but not sure how much ram and processors

Collapse
 
rangercoder99 profile image
RangerCoder99

Now try host it on a normal VPS like DigitalOcean

Collapse
 
ymc9 profile image
ymc9

Not sure if it’s a sane thing to do🀣