Skip to main content
Version: 2.x

Get Started With Next.js (app router)

Let's have some fun by creating a simple blogging app. You'll see how effortless it is to have a secure backend service without actually coding it.

You can find the final build result here.

Requirements

Our target app should meet the following requirements:

  1. Email/password-based signin/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.

Let's get started 🚀.

Prerequisite

  1. Make sure you have Node.js 18 or above installed.
  2. Install the VSCode extension for editing data models.

Building the app

1. Create a new project

The easiest way to create a Next.js project with boilerplates is with create-t3-app. Run the following command to create a new project with Prisma, NextAuth and TailwindCSS.

npx create-t3-app@latest --prisma --nextAuth --tailwind --appRouter --CI my-blog-app
cd my-blog-app

Remove DISCORD_CLIENT_ID and DISCORD_CLIENT_SECRET related code from src/env.js, since we're not going to use Discord for authentication. After that, start the dev server:

npm run dev

If everything works, you should have a running Next.js app at http://localhost:3000.

2. Initialize the project for ZenStack

Let's run the zenstack CLI to prepare your project for using ZenStack.

npx zenstack@latest init
info

The command installs a few NPM dependencies. If the project already has a Prisma schema at prisma/schema.prisma, it's copied over to schema.zmodel. Otherwise, a sample schema.zmodel file is created.

Moving forward, you will keep updating schema.zmodel file, and prisma/schema.prisma will be automatically generated from it.

3. Preparing the User model for authentication

First, in schema.zmodel, make a few changes to the User model:

/schema.zmodel
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
password String @password @omit
image String?
accounts Account[]
sessions Session[]
posts Post[]

// everyone can signup, and user profile is also publicly readable
@@allow('create,read', true)

// only the user can update or delete their own profile
@@allow('update,delete', auth() == this)
}

For simplicity, we'll use username/password-based authentication in this project. In the code above, we added a password field to support it, together with two access policy rules to control the permissions of this model.

tip
  1. @password is a ZenStack attribute that marks a field to be hashed (using bcryptjs) before saving.
  2. @omit indicates the field should be dropped when returned from a query.
info

By default, all operations are denied for a model. You can use the @@allow attribute to open up some permissions.

@@allow takes two parameters, the first is operation: create/read/update/delete. You can use a comma separated string to pass multiple operations, or use 'all' to abbreviate all operations. The second parameter is a boolean expression that verdicts if the rule should be activated.

Similarly, @@deny can be used to explicitly turn off some operations. It has the same syntax as @@allow but the opposite effect.

Whether an operation is permitted is determined as follows:

  1. If any @@deny rule evaluates to true, it's denied.
  2. If any @@allow rule evaluates to true, it's allowed.
  3. Otherwise, it's denied.

Check out Understanding Access Policies for more details.

Now run zenstack generate and prisma db push to flush the changes to the Prisma schema and database:

npx zenstack generate && npx prisma db push

4. Configure NextAuth to use credential-based auth

First, we need to install type definitions for bcryptjs

npm install --save @types/bcryptjs

Now let's update /src/server/auth.ts to the content below to use credentials auth and JWT-based session:

/src/server/auth.ts
import { PrismaAdapter } from "@auth/prisma-adapter";
import type { PrismaClient } from "@prisma/client";
import { compare } from "bcryptjs";
import {
getServerSession,
type DefaultSession,
type NextAuthOptions,
} from "next-auth";
import { type Adapter } from "next-auth/adapters";
import CredentialsProvider from "next-auth/providers/credentials";

import { db } from "~/server/db";

/**
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
* object and keep type safety.
*
* @see https://next-auth.js.org/getting-started/typescript#module-augmentation
*/
declare module "next-auth" {
interface Session extends DefaultSession {
user: {
id: string;
} & DefaultSession["user"];
}
}

/**
* Options for NextAuth.js used to configure adapters, providers, callbacks, etc.
*
* @see https://next-auth.js.org/configuration/options
*/
export const authOptions: NextAuthOptions = {
session: {
strategy: "jwt",
},
callbacks: {
session({ session, token }) {
if (session.user) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
session.user.id = token.sub!;
}
return session;
},
},
adapter: PrismaAdapter(db) as Adapter,
providers: [
CredentialsProvider({
credentials: {
email: { type: "email" },
password: { type: "password" },
},
authorize: authorize(db),
}),
/**
* ...add more providers here.
*
* Most other providers require a bit more work than the Discord provider. For example, the
* GitHub provider requires you to add the `refresh_token_expires_in` field to the Account
* model. Refer to the NextAuth.js docs for the provider you want to use. Example:
*
* @see https://next-auth.js.org/providers/github
*/
],
};

function authorize(prisma: PrismaClient) {
return async (
credentials: Record<"email" | "password", string> | undefined,
) => {
if (!credentials) throw new Error("Missing credentials");
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 prisma.user.findFirst({
where: { email: credentials.email },
select: { id: true, email: true, password: true },
});
if (!maybeUser?.password) return null;
// verify the input password with stored hash
const isValid = await compare(credentials.password, maybeUser.password);
if (!isValid) return null;
return { id: maybeUser.id, email: maybeUser.email };
};
}

/**
* Wrapper for `getServerSession` so that you don't need to import the `authOptions` in every file.
*
* @see https://next-auth.js.org/configuration/nextjs
*/
export const getServerAuthSession = () => getServerSession(authOptions);

Finally, add a NEXTAUTH_SECRET environment variable in .env file and set it to an arbitrary value (use a complex secret in production and don't check it into git):

/.env
  NEXTAUTH_SECRET=abc123

5. Mount CRUD service & generate hooks

ZenStack has built-in support for Next.js and can provide database CRUD services automagically, so you don't need to write it yourself.

First install the @zenstackhq/server, @tanstack/react-query, and @zenstackhq/tanstack-query packages:

npm install @zenstackhq/server@latest @zenstackhq/tanstack-query@latest @tanstack/react-query

Let's mount it to the /api/model/[...path] endpoint. Create a /src/app/api/model/[...path]/route.ts file and fill in the content below:

/src/app/api/model/[...path]/route.ts
import { enhance } from "@zenstackhq/runtime";
import { NextRequestHandler } from "@zenstackhq/server/next";
import { getServerAuthSession } from "~/server/auth";
import { db } from "~/server/db";

// create an enhanced Prisma client with user context
async function getPrisma() {
const session = await getServerAuthSession();
return enhance(db, { user: session?.user });
}

const handler = NextRequestHandler({ getPrisma, useAppDir: true });

export {
handler as DELETE,
handler as GET,
handler as PATCH,
handler as POST,
handler as PUT,
};

The /api/model route is now ready to access database query and mutation requests. However, manually calling the service will be tedious. Fortunately, ZenStack can automatically generate React data query hooks for you.

Let's enable it by adding the following snippet at the top level to schema.zmodel:

/schema.zmodel
plugin hooks {
provider = '@zenstackhq/tanstack-query'
target = 'react'
version = 'v5'
output = "./src/lib/hooks"
}

Now run zenstack generate again; you'll find the hooks generated under /src/lib/hooks folder:

npx zenstack generate

Now we're ready to implement the signup/signin flow.

6. Set up context providers

NextAuth and React Query require context providers to be set up. Create a /src/app/providers.tsx file with the following content:

/src/app/providers.tsx
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { SessionProvider } from "next-auth/react";
import type { ReactNode } from "react";

const queryClient = new QueryClient();

export default function Providers({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<SessionProvider>{children}</SessionProvider>
</QueryClientProvider>
);
}

Then, update /src/app/layout.tsx to install the query client provider:

/src/app/layout.tsx
"use client";

import "~/styles/globals.css";

import { Inter } from "next/font/google";
import Providers from "./providers";

const inter = Inter({
subsets: ["latin"],
variable: "--font-sans",
});

export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={`font-sans ${inter.variable}`}>
<Providers>{children}</Providers>
</body>
</html>
);
}
info

We've only done the minimum setup to get React Query to work with NextJS's app router. For more more in-depth background and setup, please refer to React Query's official documentation about Advanced Server Rendering.

7. Implement Signup/Signin

Now let's implement the signup/signin pages. First, create a new page /src/app/signup/page.tsx:

/src/app/signup/page.tsx
"use client";

import type { NextPage } from "next";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState, type FormEvent } from "react";
import { useCreateUser } from "~/lib/hooks";

const Signup: NextPage = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const { mutateAsync: signup } = useCreateUser();
const router = useRouter();

async function onSignup(e: FormEvent) {
e.preventDefault();
try {
await signup({ data: { email, password } });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error(err);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (err.info?.prisma && err.info?.code === "P2002") {
// P2002 is Prisma's error code for unique constraint violations
alert("User already exists");
} else {
alert("An unknown error occurred");
}
return;
}

// signin to create a session
await signIn("credentials", { redirect: false, email, password });
router.push("/");
}

return (
<div className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c]">
<h1 className="text-5xl font-extrabold text-white">Sign up</h1>
<form
className="mt-16 flex flex-col gap-8 text-2xl"
onSubmit={(e) => void onSignup(e)}
>
<div>
<label htmlFor="email" className="inline-block w-32 text-white">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
className="ml-4 w-72 rounded border p-2"
/>
</div>
<div>
<label htmlFor="password" className="inline-block w-32 text-white ">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
className="ml-4 w-72 rounded border p-2"
/>
</div>
<input
type="submit"
value="Create account"
className="cursor-pointer rounded border border-gray-500 py-4 text-white"
/>
</form>
</div>
);
};

export default Signup;

In the code above, we used the auto-generated useCreateUser hooks to create new User entities.

tip
  1. The services backing the hooks are governed by the access policies we defined. Here the create call can succeed because we explicitly allowed it in the User model. By default, all operations are forbidden.
  2. The password field is automatically hashed. You can confirm it using a sqlite inspection tool to browse the prisma/db.sqlite database file.

Try visiting http://localhost:3000/signup and creating a new user. It should look like this:

Similarly, create the signin page /src/app/signin/page.tsx:

/src/app/signin/page.tsx
"use client";

import type { NextPage } from "next";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState, type FormEvent } from "react";

const Signin: NextPage = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const router = useRouter();

async function onSignin(e: FormEvent) {
e.preventDefault();

const result = await signIn("credentials", {
redirect: false,
email,
password,
});

if (result?.ok) {
router.push("/");
} else {
alert("Signin failed");
}
}

return (
<div className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c]">
<h1 className="text-5xl font-extrabold text-white">Login</h1>
<form
className="mt-16 flex flex-col gap-8 text-2xl"
onSubmit={(e) => void onSignin(e)}
>
<div>
<label htmlFor="email" className="inline-block w-32 text-white">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
className="ml-4 w-72 rounded border p-2"
/>
</div>
<div>
<label htmlFor="password" className="inline-block w-32 text-white">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
className="ml-4 w-72 rounded border p-2"
/>
</div>
<input
type="submit"
value="Sign me in"
className="cursor-pointer rounded border border-gray-500 py-4 text-white"
/>
</form>
</div>
);
};

export default Signin;

8. Prepare the Post model

Now let's update the Post model to add access policies.

/schema.zmodel
model Post {
id Int @id @default(autoincrement())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
published Boolean @default(false)

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

@@index([name])

// author has full access
@@allow('all', auth() == createdBy)

// logged-in users can view published posts
@@allow('read', auth() != null && published)
}
info

The "createdById" field as a @default() attribute with auth().id value. The field will be automatically assigned with the current user's ID when creating a new record. See here for more details.

Don't forget to regenerate and push schema changes to the database:

npx zenstack generate && npx prisma db push

9. Build up the home page

Now let's replace /src/app/page.tsx with the content below and use it for viewing and managing posts.

/src/app/page.tsx
"use client";

import type { Post } from "@prisma/client";
import { type NextPage } from "next";
import { signOut, useSession } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
useFindManyPost,
useCreatePost,
useUpdatePost,
useDeletePost,
} from "../lib/hooks";

type AuthUser = { id: string; email?: string | null };

const Welcome = ({ user }: { user: AuthUser }) => {
const router = useRouter();
async function onSignout() {
await signOut({ redirect: false });
router.push("/signin");
}
return (
<div className="flex gap-4">
<h3 className="text-lg">Welcome back, {user?.email}</h3>
<button
className="text-gray-300 underline"
onClick={() => void onSignout()}
>
Signout
</button>
</div>
);
};

const SigninSignup = () => {
return (
<div className="flex gap-4 text-2xl">
<Link href="/signin" className="rounded-lg border px-4 py-2">
Signin
</Link>
<Link href="/signup" className="rounded-lg border px-4 py-2">
Signup
</Link>
</div>
);
};

const Posts = ({ user }: { user: AuthUser }) => {
// Post crud hooks
const { mutateAsync: createPost } = useCreatePost();
const { mutateAsync: updatePost } = useUpdatePost();
const { mutateAsync: deletePost } = useDeletePost();

// list all posts that're visible to the current user, together with their authors
const { data: posts } = useFindManyPost({
include: { createdBy: true },
orderBy: { createdAt: "desc" },
});

async function onCreatePost() {
const name = prompt("Enter post name");
if (name) {
await createPost({ data: { name } });
}
}

async function onTogglePublished(post: Post) {
await updatePost({
where: { id: post.id },
data: { published: !post.published },
});
}

async function onDelete(post: Post) {
await deletePost({ where: { id: post.id } });
}

return (
<div className="container flex flex-col text-white">
<button
className="rounded border border-white p-2 text-lg"
onClick={() => void onCreatePost()}
>
+ Create Post
</button>

<ul className="container mt-8 flex flex-col gap-2">
{posts?.map((post) => (
<li key={post.id} className="flex items-end justify-between gap-4">
<p className={`text-2xl ${!post.published ? "text-gray-400" : ""}`}>
{post.name}
<span className="text-lg"> by {post.createdBy.email}</span>
</p>
<div className="flex w-32 justify-end gap-1 text-left">
<button
className="underline"
onClick={() => void onTogglePublished(post)}
>
{post.published ? "Unpublish" : "Publish"}
</button>
<button className="underline" onClick={() => void onDelete(post)}>
Delete
</button>
</div>
</li>
))}
</ul>
</div>
);
};

const Home: NextPage = () => {
const { data: session, status } = useSession();

if (status === "loading") return <p>Loading ...</p>;

return (
<main className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c]">
<div className="container flex flex-col items-center justify-center gap-12 px-4 py-16 text-white">
<h1 className="text-5xl font-extrabold">My Awesome Blog</h1>

{session?.user ? (
// welcome & blog posts
<div className="flex flex-col">
<Welcome user={session.user} />
<section className="mt-10">
<Posts user={session.user} />
</section>
</div>
) : (
// if not logged in
<SigninSignup />
)}
</div>
</main>
);
};

export default Home;

Verifying the result

Restart the dev server and try creating a few posts. You should see something like the following:

The code looks a bit long because we tucked all UI components directly into the page. As you can see, querying and mutating Post entities are fairly straightforward, the generated hooks. When mutation happens, e.g. a new post is created, data refetching is also triggered automatically.

Try opening an incognito browser window and signing up for a separate account. You should find that the published posts are visible, as we specified with access policies.

Since we haven't hidden "Unpublish" and "Delete" buttons for posts not owned by the current user, you can still click them even for posts not owned to you, but it will end up with a 403 error:

network error

{
"prisma": true,
"rejectedByPolicy": true,
"code": "P2004",
"message": "denied by policy: post entities failed 'update' check, 1 entities failed policy check"
}

You can catch the error and render a nice message to the user.

Wrap up

🎉 Congratulations! You've made a simple blogging app without writing a single line of backend code. Pretty cool, isn't it?

If you have trouble following the building process, you can find the final result here. For more details about ZenStack, please refer to the Reference and Guides parts of the documentation.

Have fun building cool stuff 🚀!

Comments
Feel free to ask questions, give feedback, or report issues.

Don't Spam


You can edit/delete your comments by going directly to the discussion, clicking on the 'comments' link below