🛠️ Creating a Full-Stack Project
To simplify the process of building our full-stack Todo app, we'll recreate the project from scratch using the create-t3-app scaffolding tool - saving us a lot of time manually integrating different tools and libraries. We'll reuse the ZModel schema we built in Part I.
1. Creating the Project
Create a new Next.js project using create-t3-app
:
npx create-t3-app@latest --tailwind --nextAuth --prisma --appRouter --CI my-todo-app
It sets up a project with the following features:
- Next.js with app router
- TypeScript
- Prisma for ORM
- NextAuth.js for authentication
- Tailwind CSS for styling
- SQLite for database
We'll also use "daisyUI" for UI components. Run the following command to install it:
npm i -D daisyui@latest
Then add the "daisyui" plugin to tailwind.config.ts
:
module.exports = {
//...
plugins: [require("daisyui")],
}
Finally, add some utility packages we'll use later:
npm install nanoid
2. Setting Up ZenStack
Initialize the project for ZenStack:
npx zenstack@1 init
Besides installing dependencies, the command also copies the prisma/schema.prisma
file to schema.zmodel
. We're going to continue using the ZModel we've developed in the previous part, but with a few tweaks:
- All "id" fields are changed to String type (as required by NextAuth).
- The "markdown" and "openapi" plugins are removed (not needed for this part).
You can also find the updated version here: https://github.com/zenstackhq/the-complete-guide-sample/blob/v1-part4-start/schema.zmodel. Replace the schema.zmodel
file in your project with it.
Run generation and push the schema to the database:
npx zenstack generate && npx prisma db push
If you ran into any trouble creating the project, you can also use the "part4-start" branch of https://github.com/zenstackhq/the-complete-guide-sample as the starting point and continue from there.
3. Implementing Authentication
3.1 NextAuth Session Provider
To use NextAuth, we'll need to install a session provider at the root of our app. First, create a file src/components/SessionProvider.tsx
with the following content:
'use client';
import { SessionProvider } from 'next-auth/react';
import React from 'react';
type Props = {
children: React.ReactNode;
};
function NextAuthSessionProvider({ children }: Props) {
return <SessionProvider>{children}</SessionProvider>;
}
export default NextAuthSessionProvider;
Then, update the src/app/layout.tsx
file to use it
import NextAuthSessionProvider from '~/components/SessionProvider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className={`font-sans ${inter.variable}`}>
<NextAuthSessionProvider>{children}</NextAuthSessionProvider>
</body>
</html>
);
}
3.2 Credential-Based Auth
The default project created by create-t3-app
uses Discord OAuth for authentication. We'll replace it with credential-based authentication for simplicity.
Replace the content of /src/server/auth.ts
with the following:
import { PrismaAdapter } from '@next-auth/prisma-adapter';
import type { PrismaClient } from '@prisma/client';
import { compare } from 'bcryptjs';
import NextAuth, { type DefaultSession, type NextAuthOptions } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { db } from './db';
declare module 'next-auth' {
interface Session extends DefaultSession {
user: {
id: string;
} & DefaultSession['user'];
}
}
export const authOptions: NextAuthOptions = {
session: {
strategy: 'jwt',
},
// Include user.id on session
callbacks: {
session({ session, token }) {
if (session.user) {
session.user.id = token.sub!;
}
return session;
},
},
// Configure one or more authentication providers
adapter: PrismaAdapter(db),
providers: [
CredentialsProvider({
credentials: {
email: { type: 'email' },
password: { type: 'password' },
},
authorize: authorize(db),
}),
],
};
function authorize(prisma: PrismaClient) {
return async (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 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 };
};
}
export default NextAuth(authOptions);
Remove code related to DISCORD_CLIENT_ID
and DISCORD_CLIENT_SECRET
from /src/env.js
, and update the .env
file under project root to the following:
DATABASE_URL="file:./db.sqlite"
NEXTAUTH_SECRET="abc123"
NEXTAUTH_URL="http://localhost:3000"
You should use a strong NEXTAUTH_SECRET
in a real application.
4. Mounting the CRUD API
ZenStack uses server adapters to mount CRUD APIs to frameworks, and it has several pre-built adapters for popular frameworks - one of which is Next.js. First, install the server adapter package:
npm install @zenstackhq/server
Then, create a file src/app/api/model/[...path]/route.ts
with the following content:
import { enhance } from '@zenstackhq/runtime';
import { NextRequestHandler } from '@zenstackhq/server/next';
import { getServerSession } from 'next-auth';
import { authOptions } from '~/server/auth';
import { db } from '~/server/db';
async function getPrisma() {
const session = await getServerSession(authOptions);
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 crucial part is that we use an enhanced PrismaClient with the server adapter, so all API calls are automatically subject to the access policies defined in the ZModel schema.
In the next chapter, we'll learn how to use a plugin to generate frontend data query hooks that help us consume it.
Finally, make a change to the next.config.js
file to exclude the @zenstackhq/runtime
package from the server component bundler:
const config = {
experimental: {
serverComponentsExternalPackages: ['@zenstackhq/runtime']
}
};
Next.js's server component bundler automatically bundles dependencies, but it has some restrictions on the set of Node.js features a package can use. The @zenstackhq/runtime
package makes unsupported require()
calls. We'll try to make it compatible in a future release.
5. Compile the Project
Compile the project and see if everything is working correctly:
npm run build