DEV Community

Cover image for How to Build a Production-Ready Todo App in One Next.js Project With ZenStack
JS for ZenStack

Posted on • Edited on

How to Build a Production-Ready Todo App in One Next.js Project With ZenStack

Notice: ZenStack has recently released its 1.0 version, and this post was written based on the 0.5 version. Although the basic concept and logic has been the same, some of the code in this post is not applicable in the 1.0 version anymore. I suggest you check out the new tutorial I wrote in:


Next.js did a great job in "bringing the power of full-stack to the front end"as their slogan on the website. However, you(or your collaborator) probably still need to spend significant energy designing and building up your app's server-side part. Things that make you stressed include:

  • What kind of API to use? RESTful or GraphQL?
  • How to model your data and map the model to both source code and database (ORM)?
  • How to implement CRUD operations? Manually construct it or use a generator?
  • How to evolve your data model?
  • How to authenticate users and authorize their requests?

ZenStack aims to simplify and resolve these tasks from a front-end perspective, so we can move one step further to "bringing the power of full-stack to the front end."

This Tutorial will show you how to create a simple collaborative Todo web app below with the ZenStack library using Next.js and PostgreSQL database step by step.

The app would have below features:

  • Register/Sign-in with the email and password.
  • Create/Delete a Todo list, either public or private. Public means others could see it.
  • Create/Delete/Complete a Todo under a Todo list.

Here is an example of final result:

https://zenstack-nextjs-todo-demo.vercel.app (source)

Join the Conversation

If you have questions about anything related to this tutorial, you're welcome to ask our community on Discord.

It is a long article divided into four chapters. I will make sure you have something fun to play during the quarter break😉:

  1. Set Up The Project
  2. User Register Sign-in
  3. Todo List
  4. Todo

Let's move on!

1. Set Up The Project

  1. Create a template application using the below command:

    npx create-next-app todo --use-npm -e https://github.com/zenstackhq/nextjs-auth-postgres-template
    
  2. Install ZenStack VS code extension. So When you write your ZModel, you can get the syntax highlighting, linting, code completion, formatting, and jump-to-definition in VScode same as writing Typescript:

VS code extension

Let me show you what the ZModel is.

2. User Register Sign-in

ZModel

ZModel is the core part of the ZenStack library. You can think of it as the "Class" in any Object-oriented programming(OOP), including Typescript we are using now, with the main important characteristic: the instance of it will be persisted in the database.

If you are familiar with Object-Relational Mapping(ORM), you might wonder if this is the front-end version of ORM. Yes, but it's only part of it. Besides the data structure usually defined by ORM, ZModel also contains data access policies just like the logic of the Class.

Therefore, the first and foremost job of using ZenStack is to define the Model appropriately. After that, you can 99% focus on front-end development like you used to do. So let's start with the first Model.

All the models should be defined in the schema file zenstack/schema.zmodel under the root of your project. It should have been generated by the template, open it you can find the below snippet at the top:

datasource db {
    provider = 'postgresql'
    url = env('DATABASE_URL')
}
Enter fullscreen mode Exit fullscreen mode

The datasource at the top determines how ZenStack will connect the database. We will use Postgres as our database. So what you need to do is to add your database connection URL in the .env file as below:

DATABASE_URL="postgres://postgres:[YOUR-PASSWORD]@[YOUR-URL]/postgres"
Enter fullscreen mode Exit fullscreen mode

If you don't have a Postgres database, the simple way to get one is to get a docker instance or a free one from Supabase.

Note: if you want to play around in your local machine without getting a Postgres instance, you can also use SQLite, a file-based database. You can change the datasource as below:

datasource db {
    provider="sqlite"
    url="file:./dev.db"
}
Enter fullscreen mode Exit fullscreen mode

User ZModel

The rest part of the schema file is the definition of the User ZModel:

/*
 * User model
 */
model User {
  id String @id @default(uuid())
  email String @unique
  emailVerified DateTime?
  // @password indicates the field is a password and its
  // value should be hashed (with bcrypt) before storing
  // @omit indicates the field should not be returned on read
  password String @password @omit
  name String?

  // everybody can signup
  @@allow('create', true)
  // can only be updated and deleted by self
  @@allow('read,update,delete', auth() == this)
  }
Enter fullscreen mode Exit fullscreen mode

The Model consists of two parts:

  • Data structure. It is defined by the individual field. Each field should have a name, type, and optional attributes. The name and type have no different from any other language. The optional attribute adds a constraint to the field. Let's go through them one by one:
    • @id. It is used to identify an individual record uniquely. Every Model must have an ID. Internally It is mapped as the primary key of the table in the database.
    • @default(cuid()). It is used to give a default value for a field so that you don’t have to give the value for it when creating it. The cuid() is to generate a globally unique identifier that has a better lookup performance than uuid(). It is usually used together with @id to generate business independent id.
    • @unique. It defines a unique constraint for the email field. If you are trying to create an instance with an email that has existed, it will throw an error.
    • @password. It indicates the field is a password, and its value should be hashed (with bcrypt) before storing.
    • @omit . It indicates the field should not be returned on read, which means it should only be accessed from the backend, like secret, password, etc.

This part is what an ORM provides. Internally, it would be converted into the Prisma schema. Now it is fully compatible with Prisma schema. So to get the complete list of what you can use, please take a reference with the Prisma schema document.

  • Access policy. As mentioned above, ZenStack provides a data access policy over the traditional ORM. Let's take a look at the two policies used here:
    • @@allow('create', true). It means you can always create a new user, no matter your identity. We need to specify it explicitly because, by default, all the operation is forbidden from the front end. You need to open it explicitly.
    • @@allow('read,update,delete', auth() == this). It means only the current user instance could be read/written by himself. ZModel allows you to reference the current login user via auth() function in access policy expressions. So when the front-end sends the query to the backend through the API provided by ZenStack library, it will first go through the guard code generated by ZenStack in the backend to ensure it would only access the data comply with the policy. Therefore front-end code could safely call the API to implement the business logic without worrying about access control.

You can find the complete ZModel language definition here.

Authentication

Almost every modern application has authentication now, which the access policy is based on as the auth() function you have seen. To simplify the task, ZenStack has integrated with the open source authentication library NextAuth, which we will use in this demo.

NextAuth

If you are familiar with the NextAuth, you can skip this part.

NextAuth has vast coverage of providers ranging from Google, Github, Facebook, Apple, Slack, Twitter, etc. We will use the basic email/password login to avoid external dependency.

Below I've included a description of how to use NextAuth in the Next project. You can get more detailed instructions from the official document of NextAuth.

  • Create a file called [...nextauth].jsin pages/api/authThis contains the dynamic route handler for NextAuth.js which will also contain all of your global NextAuth.js configurations.

    export const authOptions: NextAuthOptions = {
      session: {
        strategy: "jwt",
      },
    
      providers: [
        CredentialsProvider({
          credentials: {
            email: {
              label: "Email Address",
              type: "email",
              placeholder: "Your email address",
            },
            password: {
              label: "Password",
              type: "password",
              placeholder: "Your password",
            },
          },
        }),
      ],
     callbacks: {
      async session({ session, token, user }) {
      //Send properties to the client, like an access_token from a provider.
        session.accessToken = token.accessToken
        return session
      }
    }
    };
    
    export default NextAuth(authOptions);
    

    The CredentialsProvider is used for the email/password login and uses JWT token to store the session information in the client.

    session()callback function is used to add additional information to the client when calling useSession() below.

  • Get session data from the front-end

    NextAuth provides a useSession() React Hook to check if someone is signed in like below:

    const { data: session } = useSession()
      if (session) {
        return (
          <>
            Signed in as {session.user.email} <br />
            <button onClick={() => signOut()}>Sign out</button>
          </>
        )
      }
      return (
        <>
          Not signed in <br />
          <button onClick={() => signIn()}>Sign in</button>
        </>
      )
    

    To be able to use it, first, you'll need to expose the session context,<SessionProvider/>, at the top level of your application:

    import { SessionProvider } from "next-auth/react"
    export default function App({
      Component,
      pageProps: { session, ...pageProps },
    }) {
      return (
        <SessionProvider session={session}>
          <Component {...pageProps} />
        </SessionProvider>
      )
    }
    
  • Get session data from the back-end

    You can use the unstable_getServerSession()method to retrieve the session data like the below:

    import { unstable_getServerSession } from "next-auth/next"
    import { authOptions } from "./auth/[...nextauth]"
    export default async (req, res) => {
      const session = await unstable_getServerSession(req, res, authOptions)
      if (session) {
        res.send({
          content:
            "This is protected content. You can access this content because you are signed in.",
        })
      } else {
        res.send({
          error: "You must be signed in to view the protected content on this page.",
        })
      }
    }
    
  • Persist user data in the database

    NextAuth support creating an Adapter to connect your application to whatever database or backend system. ZenStack has implemented its own adapter, you will see how to use it below.

ZenStack Adapter

Let's see how to hook up with the ZenStack Adapter in our demo.

  1. The first thing required is to have a user entity defined to include specific fields. The user ZModel defined above meets the requirement, so there is nothing you need to change. The Adapter is hooked in pages/api/auth/**[...nextauth].js**

    import NextAuth, { NextAuthOptions, User } from "next-auth";
    import CredentialsProvider from "next-auth/providers/credentials";
    import { authorize, NextAuthAdapter as Adapter } from "@zenstackhq/runtime/auth";
    import service from "@zenstackhq/runtime";
    
    export const authOptions: NextAuthOptions = {
      // use the ZenStack next-auth adapter for user identity persistence
      adapter: Adapter(service),
    
      session: {
        strategy: "jwt",
      },
    
      providers: [
        CredentialsProvider({
          credentials: {
            email: {
              label: "Email Address",
              type: "email",
              placeholder: "Your email address",
            },
            password: {
              label: "Password",
              type: "password",
              placeholder: "Your password",
            },
          },
          // use the "authorize" helper generated by ZenStack to authenticate user login
          authorize: authorize(service),
        }),
      ],
    
      callbacks: {
        async session({ session, token }) {
          // unbox the user entity from session and get userId from JWT token
          return {
            ...session,
            user: {
              ...session.user,
              id: token.sub!,
            },
          };
        },
      },
    };
    
    export default NextAuth(authOptions);using 
    

    authorize() function generated by ZenStack would actually check the email/password provided by the client to see whether sign-in is allowed when calling signIn() provided by NextAuth.

  2. The value returned by auth()is provided via the getServerUserhook function when mounting ZenStack APIs in pages/api/zenstack/[…path].ts :

    import { NextApiRequest, NextApiResponse } from "next";
    import { type RequestHandlerOptions, requestHandler } from "@zenstackhq/runtime/server";
    import { authOptions } from "../auth/[...nextauth]";
    import { unstable_getServerSession } from "next-auth";
    import service from "@zenstackhq/runtime";
    
    const options: RequestHandlerOptions = {
      async getServerUser(req: NextApiRequest, res: NextApiResponse) {
        const session = await unstable_getServerSession(req, res, authOptions);
        return session?.user;
      },
    };
    export default requestHandler(service, options);
    
  3. Add <SessionProvider/>

    import { SessionProvider } from "next-auth/react";
    import type { AppProps } from "next/app";
    import type { Session } from "next-auth";
    
    function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps<{ session: Session }>) {
      return (
        <SessionProvider session={session}>
          <div className="flex-grow h-100">
            <Component {...pageProps} />
          </div>
        </SessionProvider>
      );
    }
    
    export default MyApp;
    
  4. Use useSession() to see whether the user is successfully checked in.

    import React from "react";
    import { signIn, signOut, useSession } from "next-auth/react";
    
    export default function Home() {
      const { status, data } = useSession();
    
      if (status === "loading") {
        return <p>Loading...</p>;
      } else if (status === "unauthenticated") {
        signIn();
        return <></>;
      } else {
        const authUser = data?.user;
        return (
          <>
            {authUser && (
              <div className="mt-8 text-center flex flex-col items-center w-full">
                <h1 className="text-2xl text-gray-800">Hello World!</h1>
                <button onClick={() => signOut()}>logout</button>
              </div>
            )}
          </>
        );
      }
    }
    

Sign up

With sign-in taken care of by the Adapter, we need to implement the signup function, which means we need to be able to create a user entity.

As mentioned above, ZenStack would generate React Hook for each ZModel type. So for User, the userUser() hook is generated in @zenstackhq/runtime/hooks

export declare function useUser(): {
    create: <T extends P.UserCreateArgs>(args: P.UserCreateArgs) => Promise<P.CheckSelect<T, User, P.UserGetPayload<T, keyof T>>>;
    find: <T_1 extends P.UserFindManyArgs>(args?: P.SelectSubset<T_1, P.UserFindManyArgs> | undefined) => SWRResponse<P.CheckSelect<T_1, User[], P.UserGetPayload<T_1, keyof T_1>[]>, any>;
    get: <T_2 extends P.Subset<P.UserFindFirstArgs, "select" | "include">>(id: String, args?: P.SelectSubset<T_2, P.Subset<P.UserFindFirstArgs, "select" | "include">> | undefined) => SWRResponse<P.CheckSelect<T_2, User, P.UserGetPayload<T_2, keyof T_2>>, any>;
    update: <T_3 extends Omit<P.UserUpdateArgs, "where">>(id: String, args: Omit<P.UserUpdateArgs, 'where'>) => Promise<P.CheckSelect<T_3, User, P.UserGetPayload<T_3, keyof T_3>>>;
    del: <T_4 extends Omit<P.UserDeleteArgs, "where">>(id: String, args?: Omit<P.UserDeleteArgs, 'where'>) => Promise<P.CheckSelect<T_4, User, P.UserGetPayload<T_4, keyof T_4>>>;
};
Enter fullscreen mode Exit fullscreen mode

You can call these CRUD functions to access the data without explicitly writing HTTP requests.

So let's create a signup page in page/signup.tsx

import React, { useState } from "react";
import Router from "next/router";
import { useUser } from "@zenstackhq/runtime/hooks";
import { signIn } from "next-auth/react";

const SignUp: React.FC = () => {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const { create: signup } = useUser();

  const submitData = async (e: React.SyntheticEvent) => {
    e.preventDefault();
    try {
      await signup({
        data: {
          email,
          name,
          password,
        },
      });

      const signInResult = await signIn("credentials", {
        redirect: false,
        email,
        password,
      });
      if (signInResult?.ok) {
        await Router.push("/");
      } else {
        console.error("Signin failed:", signInResult?.error);
      }
    } catch (error) {
      console.error(error);
      alert("This email has been registered");
    }
  };

  return (
    <>
      <div className="page">
        <form onSubmit={submitData}>
          <h1>Signup user</h1>
          <input autoFocus onChange={(e) => setName(e.target.value)} placeholder="Name" type="text" value={name} />
          <input onChange={(e) => setEmail(e.target.value)} placeholder="Email address" type="text" value={email} />
          <input
            onChange={(e) => setPassword(e.target.value)}
            placeholder="Your password"
            type="password"
            value={password}
          />
          <input disabled={!name || !email || !password} type="submit" value="Signup" />
          <a className="back" href="#" onClick={() => Router.push("/")}>
            or Cancel
          </a>
        </form>
        <p>
          Already have an account?{" "}
          <a
            className="signin"
            onClick={() =>
              signIn(undefined, {
                callbackUrl: "/",
              })
            }
          >
            Signin now
          </a>
          .
        </p>
      </div>
      <style jsx>{`
        .page {
          background: white;
          padding: 3rem;
          display: flex;
          flex-direction: column;
          justify-content: center;
        }
        input[type="text"],
        input[type="password"] {
          width: 100%;
          padding: 0.5rem;
          margin: 0.5rem 0;
          border-radius: 0.25rem;
          border: 0.125rem solid rgba(0, 0, 0, 0.2);
        }
        input[type="submit"] {
          background: #ececec;
          border: 0;
          padding: 1rem 2rem;
          cursor: pointer;
        }
        .signin {
          cursor: pointer;
          text-decoration: underline;
        }
        .back {
          margin-left: 1rem;
        }
      `}</style>
    </>
  );
};

export default SignUp;
Enter fullscreen mode Exit fullscreen mode

The submitData function shows that signup is actually simply calling the create function of userUser() hook provided by ZenStack. Since@password attribute on the password property will hash the password in the back end, so we could only pass the plain text here.

If the signup succeeds, it will automatically call the signIn of NextAuth to get the user signed in and then go to the home page.

Generate From Schema

Once you have the schema ready, there are two things you need to do before running the app.

Firstly, we need to generate all the code stubs from the ZModel schema by running:

npm run generate
Enter fullscreen mode Exit fullscreen mode

You should see the output below :

✔️ Prisma schema and query guard generated
  ✔️ ZenStack service generated
  ✔️ React hooks generated
  ✔️ Next-auth adapter generated
  ✔️ Typescript source files transpiled
👻 All generators completed successfully! 
Enter fullscreen mode Exit fullscreen mode
  • The Prisma schema named 'schema.prisma' is generated beside your ZModel file.
  • The React Hook to access User is generated under @zenstackhq/runtime/hooks.
  • Next-auth adapter is generated @zenstackhq/runtime/auth
  • Typescript type definition of User is generated under @zenstackhq/runtime/types

Secondly, We need to sync the database with the schema. If you haven't added DATABASE_URL in the .env, you can add it now. Then run:

npm run db:push
Enter fullscreen mode Exit fullscreen mode

You should see the output below:

🚀  Your database is now in sync with your Prisma schema. Done in 2.56s
Enter fullscreen mode Exit fullscreen mode

Check your database. There should be a User table generated.

Run the Code

The template project has all it needs to run for the complete user registration and sign-in. So now you can run it as a standard Next.js app by:

npm run build & npm run dev
Enter fullscreen mode Exit fullscreen mode

Then visit http://localhost:3000/singup, you should be able to see the signup form:

Signup Form

After signup with your account information, you will see the familiar message:

Hello world

Now run the below command:

npm run db:browse
Enter fullscreen mode Exit fullscreen mode

It will open a page in http://localhost:5555/ and show you the data stored in the database. You should be able to see your user record:

Database record

Query User Data

We have seen how to create the user entity in the signup chapter above. Next, let's see how to query it.

Similar with create, it uses the get hook of useUser. So you need to pass the user id getting from the useSession. So Let’s open index.tsx and change it to below:

import React from "react";
import { signIn, signOut, useSession } from "next-auth/react";
import { useUser } from "@zenstackhq/runtime/hooks";

export default function Home() {
  const { status, data } = useSession();
  const { get } = useUser();
  const { data: user } = get(data?.user.id!);

  if (status === "loading") {
    return <p>Loading...</p>;
  } else if (status === "unauthenticated") {
    signIn();
    return <></>;
  } else {
    return (
      <>
        {user && (
          <div className="mt-8 text-center flex flex-col items-center w-full">
            <h1 className="text-2xl text-gray-800">Welcome {user.name || user.email}!</h1>
            <button onClick={() => signOut()}>logout</button>
          </div>
        )}
      </>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Make sure the server is still running, and revisit http://localhost:3000. You should see your user name displayed on the screen:

welcome message

If you open the Network in the developer tool panel, you can find the actual HTTP request to get data with the URL looks like below:

http://localhost:3000/api/zenstack/data/User/cl9tqaibt0000t2sskmmw7g3r

cl9tqaibt0000t2sskmmw7g3r is the id of the user ZModel

Polishing Layout

So far, we have used minimal code to illustrate how the ZenStack library works for you. Before we move on, let's polish our page styling to make it look natural.

We choose to daisyUI component with TailWindCSS utility. We will create a Navigation bar for it. As it's pure front-end Layout work, we will not dive into the detail in this tutorial.

The page looks like this now:

Polished home page

We also replace the default login page with a custom one implemented using Chakra UI components. Again, you can look at how to do that from the official NextAuth document.

Moreover, we combined it with the signup page together, so it looks like this now:

Combined singin/up

You can check out the finished code under:

https://github.com/zenstackhq/zenstack-nextjs-todo-demo/tree/signin

3. Todo List

After signing in, the user would first create a Todo list. So let's start to implement a Todo list. Like what we did for the user, it should always start with defining the ZModel. There are two parts of a ZModel: data structure and access policy.

Data Structure

To be simple, let's use List as the name for this Model. So let's consider what data we need for a Todo list entity.

  • A unique id

    id String @id @default(uuid())
    
  • Create/Update time

    createdAt DateTime @default(now())
    updatedAt DateTime @updatedAt
    

    We can use now() to let the library generate it automatically when creating so that we don’t have to specify the value ourselves. The @updateAt will automatically set the field value when this entity is updated.

  • A title

    title String
    
  • A private flag. If you remember our Todo list could be either public or private

    Boolean @default(false)
    
  • Owner. To whom this Todo list belongs.

    This comes to an essential concept Relations. A relation is a connection between two models in the ZModel schema. In our case, the relation between User and Todo list is that a Todo list must belong to a User; On the other hand, a User could have many Todo lists. This is called One-to-Many relation. The other two One-to-One and Many-to-Many relations will not be covered in this tutorial.

    Relation is the predefined constraint between the Model to make, which is usually implemented at the database level. So data consistency would be guaranteed, meaning you can't write the inconsistent data like a non-existing user to be the owner of a Todo list.

    Since the relation is between two models, defining it also involves two models.

    Todo list ZModel

    ownerId String
    owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
    
    • ownerId. The value of this field equals to the id of the User entity it belongs to. This is what actually persisted in the database. This field is referenced by the @relation attribute.
    • owner. This is the object reference to the User entity. It would not be persisted in the database. The value would be set by the ZenStack library through the fields and references parameters of @relation attribute.
      • fields tells which fields in the current model store the unique id of the referenced entity. In our case, it is the ownerId above.
      • references tells which fields are the unique id in the referenced Model. In our case, it is the id of the User ZModel.
      • onDelete tells what we should do about this record if the referenced record is deleted. Cascade means if a user is deleted, then all the Todo list belonging to him would be deleted automatically too.

    User ZModel

    This part is simple. We only need to add the reference to all the Todo Lists belonging to this user. So please add the blew field in the User Model:

    model User {
    ...
    lists List[]
    ...
    }
    

    The benefit of this reversed reference is that when you query the user, you can also get all the Todo lists belonging to this user.

    Internally the relation is handled by Prisma too, so if you want to know more about it, please check out the Prisma document.

The complete Todo list Model data structure would be:

model List {
    id String @id @default(uuid())
    createdAt DateTime @default(now())
    updatedAt DateTime @updatedAt
    title String
    private Boolean @default(false)
    ownerId String
    owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
}
Enter fullscreen mode Exit fullscreen mode

Access Policy

ToDo List

The access policy depends on the functionality from the business side. So let's go through it one by one:

Firstly, two standard rules probably apply to most cases in web applications:

  • You can't access it without login
// require login
@@deny('all', auth() == null)
Enter fullscreen mode Exit fullscreen mode
  • The owner has the full access. Remember we have the owner reference field, so we can use it to compare with the User entity returned by auth(). Internally it will check the equality using id.
// owner has full access
@@allow('all', auth() == owner)
Enter fullscreen mode Exit fullscreen mode

Then there is a related business functionality: if the Todo list is public, it could also be seen by others without modification.

// can be read by anyone if it is public 
@@allow('read', !private)
Enter fullscreen mode Exit fullscreen mode

The logic of permitting/rejecting an operation is as follows:

  • By default, all operations are rejected if there isn't any @@allow rule in a model
  • The operation is rejected if any of the conditions in @@deny rules evaluate to true
  • Otherwise, the operation is permitted if any of the conditions in @@allow rules evaluate to true
  • Otherwise, the operation is rejected

User

If you can see other's Todo list, you would also need to know to whom it belongs. However, the current policy of user Model only allow read by self, so we need to change it:

  // everybody can signup
  @@allow('create', true)
  // can be read by other users
  @@allow('read', auth() != null)
  // can only be updated and deleted by self
  @@allow('update,delete', auth() == this)
Enter fullscreen mode Exit fullscreen mode

Generate From Schema

This is the same step as what we did for the User registration steps by running the below two commands:

// generate code stub
npm run generate
// sync with database
npm run db:push
Enter fullscreen mode Exit fullscreen mode

Write Front-End Code

With the schema ready, let's focus on the front-end implementation. Firstly, Let's add two more dependencies we will use:

npm i moment
npm i @heroicons/react
Enter fullscreen mode Exit fullscreen mode

Todo list Component

Then let's create the Todo list UI component in components/TodoList.tsx with the below props:

import { List, User } from "@zenstackhq/runtime/types";

type Props = {
  value: List & { owner: User };
};
Enter fullscreen mode Exit fullscreen mode

As this is a Todo list, it needs a Todo list entity to render. ZenStack will generate the corresponding typescript type definition for every Model under @zenstackhq/runtime/types. So we can directly use it here.

One thing to note is that although the ZenStack library can set the reference type in the query result, the type definition doesn't have that field. So you need to specify it to pass the type checking explicitly.

Next Let’s implement the delete function:

import { useList } from "@zenstackhq/runtime/hooks";

export default function TodoList({ value, deleted }: Props) {
  const router = useRouter();

  const { del } = useList();

  const deleteList = async () => {
    if (confirm("Are you sure to delete this list?")) {
      try {
        await del(value.id);
      } catch (error: any) {
        if (error.status == 403) {
          alert("You are now allowed to do so");
        }

    }
  };
}
Enter fullscreen mode Exit fullscreen mode

To execute the delete operation, all you need to do is call del hooks provided by ZenStack library and pass the id of the list. Since the hooks is guarded by the access policy, you should always handle the exception when the policy check fails.

Since the Todo list entity has the createdAt/updatedAt field, we would like to show the date on the list. So, let's create a TimeInfo component for it:

import moment from "moment";

type Props = {
  value: { createdAt: Date; updatedAt: Date };
};

export default function TimeInfo({ value }: Props) {
  return (
    <p className="text-sm text-gray-500">
      {value.createdAt === value.updatedAt
        ? `Created ${moment(value.createdAt).fromNow()}`
        : `Updated ${moment(value.updatedAt).fromNow()}`}
    </p>
  );
}
Enter fullscreen mode Exit fullscreen mode

Also, we use picsum.photos to show image covers. To use it, add the below in the next.config.js:

    images: {
    domains: ["picsum.photos"],
  },
Enter fullscreen mode Exit fullscreen mode

The complete code for Todo list component is below:

import Image from "next/image";
import { List, User } from "@zenstackhq/runtime/types";
import { customAlphabet } from "nanoid";
import { LockClosedIcon, TrashIcon } from "@heroicons/react/24/outline";
import Avatar from "./Avatar";
import Link from "next/link";
import { useRouter } from "next/router";
import { useList } from "@zenstackhq/runtime/hooks";
import TimeInfo from "./TimeInfo";

type Props = {
  value: List & { owner: User };
  deleted?: (value: List) => void;
};

export default function TodoList({ value, deleted }: Props) {
  const router = useRouter();

  const { del } = useList();

  const deleteList = async () => {
    if (confirm("Are you sure to delete this list?")) {
      try {
        await del(value.id);
      } catch (error: any) {
        if (error.status == 403) {
          alert("You are not allowed to do so");
        }
      }
      if (deleted) {
        deleted(value);
      }
    }
  };

  return (
    <div className="card w-80 bg-base-100 shadow-xl cursor-pointer hover:bg-gray-50">
      <a>
        <figure>
          <Image
            src={`https://picsum.photos/300/200?r=${value.id}`}
            width={320}
            height={200}
            alt="Cover"
          />
        </figure>
      </a>
      <div className="card-body">
        <Link href={`${router.asPath}${value.id}`}>
          <a>
            <h2 className="card-title line-clamp-1">{value.title || "Missing Title"}</h2>
          </a>
        </Link>
        <div className="card-actions flex w-full justify-between">
          <div>
            <TimeInfo value={value} />
          </div>
          <div className="flex space-x-2">
            <Avatar user={value.owner} size={18} />
            {value.private && (
              <div className="tooltip" data-tip="Private">
                <LockClosedIcon className="w-4 h-4 text-gray-500" />
              </div>
            )}
            <TrashIcon
              className="w-4 h-4 text-gray-500 cursor-pointer"
              onClick={() => {
                deleteList();
              }}
            />
          </div>
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Create Todo list

You can use the create hooks to create a Todo list. Since both createdAt/updated will be handled automatically, we don't need to specify it

const { create } = useList();

await create({
        data: {
          title,
          ownerId: user!.id,
          private: _private,
        },
      });
Enter fullscreen mode Exit fullscreen mode

Get all the Todo list

You can use the find hooks to retrieve all the Todo list the current user could see:

const { find } = useList();

const { data: lists } = find({
  include: {
    owner: true,
  },
  orderBy: {
    updatedAt: "desc",
  },
});
Enter fullscreen mode Exit fullscreen mode

By default, the related entities wouldn’t be retrieved for performance issues. If you want it in the result, you need to specify it in the include property.

Let's replace the index.tsx file with the below content to include the two features above:

import { UserContext } from "@lib/context";
import { ChangeEvent, FormEvent, useContext, useState } from "react";
import { useList } from "@zenstackhq/runtime/hooks";
import TodoList from "components/TodoList";

function CreateDialog() {
  const user = useContext(UserContext);

  const [modalOpen, setModalOpen] = useState(false);
  const [title, setTitle] = useState("");
  const [_private, setPrivate] = useState(false);

  const { create } = useList();

  const onSubmit = async (event: FormEvent) => {
    event.preventDefault();

    try {
      await create({
        data: {
          title,
          ownerId: user!.id,
          private: _private,
        },
      });
    } catch (err) {
      alert(`Failed to create list: ${err}`);
      return;
    }

    // reset states
    setTitle("");
    setPrivate(false);

    // close modal
    setModalOpen(false);
  };

  return (
    <>
      <input
        type="checkbox"
        id="create-list-modal"
        className="modal-toggle"
        checked={modalOpen}
        onChange={(e: ChangeEvent<HTMLInputElement>) => {
          setModalOpen(e.currentTarget.checked);
        }}
      />
      <div className="modal">
        <div className="modal-box">
          <h3 className="font-bold text-xl mb-8">Create a Todo list</h3>
          <form onSubmit={onSubmit}>
            <div className="flex flex-col space-y-4">
              <div className="flex items-center">
                <label htmlFor="title" className="text-lg inline-block w-20">
                  Title
                </label>
                <input
                  id="title"
                  type="text"
                  required
                  placeholder="Title of your list"
                  className="input input-bordered w-full max-w-xs mt-2"
                  value={title}
                  onChange={(e: FormEvent<HTMLInputElement>) => setTitle(e.currentTarget.value)}
                />
              </div>
              <div className="flex items-center">
                <label htmlFor="private" className="text-lg inline-block w-20">
                  Private
                </label>
                <input
                  id="private"
                  type="checkbox"
                  className="checkbox"
                  onChange={(e: FormEvent<HTMLInputElement>) => setPrivate(e.currentTarget.checked)}
                />
              </div>
            </div>
            <div className="modal-action">
              <input className="btn btn-primary" type="submit" value="Create" />
              <label htmlFor="create-list-modal" className="btn btn-outline">
                Cancel
              </label>
            </div>
          </form>
        </div>
      </div>
    </>
  );
}

export default function Home() {
  const { find } = useList();

  const { data: lists } = find({
    include: {
      owner: true,
    },
    orderBy: {
      updatedAt: "desc",
    },
  });

  return (
    <>
      <div className="p-8">
        <div className="w-full flex flex-col md:flex-row mb-8 space-y-4 md:space-y-0 md:space-x-4">
          <label htmlFor="create-list-modal" className="btn btn-primary btn-wide modal-button">
            Create a list
          </label>
        </div>

        <ul className="flex flex-wrap gap-6">
          {lists?.map((list) => (
            <li key={list.id}>
              <TodoList value={list} />
            </li>
          ))}
        </ul>

        <CreateDialog />
      </div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

The complete code of this version is under:

https://github.com/zenstackhq/zenstack-nextjs-todo-demo/tree/list

Run it

Let’s log in with User A and create one public list and one private list like below:

Todo list result

Then let’s login with another User B, then you could only see the Public list

Todo list another user

And If you try to delete this by user B you will see the error below:

Delete error

Isn't it cool that all these things would be handled by the access policy that you don't need to worry about in your code? 😎

4. Todo

Let's implement the last part of this tutorial, the Todo. Let's begin with the ZModel, as always:

Data Structure

The data structure is like below:

model Todo {
    id String @id @default(uuid())
    createdAt DateTime @default(now())
    updatedAt DateTime @updatedAt
    owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
    ownerId String
    list List @relation(fields: [listId], references: [id], onDelete: Cascade)
    listId String
    title String
    completedAt DateTime?
}
Enter fullscreen mode Exit fullscreen mode

With the experience of Todo list, there is nothing new for you except it has two reference field owner and list.

Access Policy

The access policy is like below:

// require login
@@deny('all', auth() == null)
// list owner has full access 
@@allow('all', list.owner == auth())
// can be read by anyone if is public 
@@allow('read', !list.private)
Enter fullscreen mode Exit fullscreen mode
  1. The first one is the same as Todo.
  2. The second one means the owner of the list this Todo belongs to has the full access, which is done by referencing the list's field list.owner. Why don't we use owner == auth(), like Todo? The problem is that then you could create a Todo under another person's public list.
  3. It means if the list is public, then the Todo under it should be public too.

Write Your Own Code

Since you have been here, how about writing the Todo implementation by your own. Use this as a test to see how much you have learned about using the ZenStack library. 💪

Don't worry if you got blocked by something. You can always take a reference of our implementation below:

https://github.com/zenstackhq/zenstack-nextjs-todo-demo/tree/todo

Finally

Congratulations on finishing this long journey! ZenStack is an open-source project in its infant stage. If you think it's useful, let's raise it together. Let us know your thought on Twitter, Discord, GitHub, or whatever you like.

Coming next would be episode 2 which I will show you how to add space/organization to make it like an actual SAAS product

Top comments (6)

Collapse
 
reytortugo profile image
ReyTortuga

Very well exaplined! Love it! Congrats! :D

Collapse
 
jiasheng profile image
JS

Thanks! Hope ZenStack can help you with your project. Feel free to share any question or idea 😄

Collapse
 
jerrzhang profile image
JerrZhang

Very interesting, keep watching...

Collapse
 
jiasheng profile image
JS

Thanks!

Collapse
 
adent profile image
adent

Good write up. A bit long for a tutorial but fairly easy to follow.

Collapse
 
jiasheng profile image
JS

Thanks! There is an episode 2 coming soon 😉