Next.js Bring Your Own Database (BYOD) Series- Part 1 Prisma

How to set up your Next.js with Prisma and NextAuth

Introduction:

In this Next.js Bring Your Own Database (BYOD) series, where we empower you to choose the perfect database solution for your next React application built with Next.js. We'll explore various database options, demonstrating how each can be integrated seamlessly into your project while providing insights into their strengths, weaknesses, and use cases. By the end of this series, you'll have a solid understanding of the available options, enabling you to make an informed decision about the best database technology for your specific needs.

Next.js is a popular framework for building React applications, offering incredible performance, ease of use, and flexibility. It's the perfect foundation for building modern web applications, but choosing the right database can be a daunting task. That's where our Next.js BYOD series comes in.

Throughout this series, we will take you on a journey through three leading backend technologies – Prisma, Supabase, and AWS Amplify with DynamoDB – to showcase how you can create a powerful and flexible application that caters to your specific requirements. By demonstrating how to build a fully-functional poll application with each of these technologies, we'll provide you with a clear starting point for creating your own apps while highlighting the benefits and drawbacks of each approach.

In addition to exploring various database solutions, this series will also cover the integration of popular OAuth providers such as Google for authentication and authorization. Implementing user login and managing access to application features like voting on poll options is a crucial aspect of building a secure and user-friendly web application. By incorporating OAuth providers in our Next.js BYOD series, we'll ensure that your app not only benefits from a powerful backend but also takes advantage of secure and trusted authentication methods.

Furthermore, we'll demonstrate how to set up a common UI library, allowing you to decouple your frontend from your backend and ensure that your application remains adaptable as your needs evolve. By the end of this series, you'll be equipped with a powerful toolkit to build your next project, with the confidence to choose the ideal database solution for your unique requirements. Let's begin!

Build the Backend:

To begin, let's clone the Next.js-BYOD repository so we can access the byod-ui library. After cloning the repo, navigate to the directory and perform a git checkout of the commit where only the byod-ui library has been added, and the Prisma-Next.js app has not yet been created:

git clone https://github.com/CaptainChemist/Next.js-BYOD
cd Next.js-BYOD
git checkout -b prisma-nextjs f2128f0cbe9c9974ea23cd64885a8329c92bd3e9

Next, create the Next.js project:

npx create-next-app prisma-nextjs
cd prisma-nextjs

During the setup process, select 'yes' for typescript, 'yes' for eslint, 'yes' for the src/ directory, 'no' for the app/ directory, and use the default alias. Then, create a .env file in the root of the project. Let's set up the Google OAuth 2.0 client, which we will use throughout our projects.

a. Go to the Google API Console

b. Go to "Credentials" in the left sidebar, click "Create Credentials," and select "OAuth client ID."

c. Select "Web application" as the application type.

d. Provide an "Authorized JavaScript origin" URL, which should be http://localhost:3000 for local development.

e. Provide an "Authorized redirect URI" in the format http://localhost:3000/api/auth/callback/google.

f. Click "Create" and note the generated "Client ID" and "Client secret."

Finally, create a .env file at the root of your project and add the following variables:

GOOGLE_CLIENT_ID=your-client-id
GOOGLE_CLIENT_SECRET=your-client-secret

Now let's add dependencies. We use prisma as a typescript connector between our backend api route in Next.js and our postgres database. Apollo is our GraphQL client while Graphql-Yoga is our GraphQL server that gets all of its schema and resolver information from pothos. Finally, Tailwind is our css library.

npm install @prisma/client @apollo/client next-auth @next-auth/prisma-adapter @pothos/core @pothos/plugin-prisma graphql-yoga
npm install --save-dev autoprefixer prisma postcss tailwindcss

Now we can initialize prisma:

npx prisma init

This will create a prisma folder and add to our .env. Feel free to reorganize the file if you'd like. You have several options for your database. For local development, feel free to install postgres locally and use the default postgres / postgres as the user/admin login:

DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"

Now, we'll create the schema to apply it to the database. It consists of five models: User, Poll, Question, Choice, and Vote. The relationships between the models are established as follows:

  • A user can create multiple polls and cast multiple votes.
  • A poll has multiple options.
  • An option can have many votes associated with it.
  • Accounts, Sessions, and Verifications are boilerplate configurations required by NextAuth.

prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgres"
  url      = env("DATABASE_URL")
}

generator pothos {
  provider = "prisma-pothos-types"
}

model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String? @db.Text
  access_token      String? @db.Text
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String? @db.Text
  session_state     String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model VerificationToken {
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
}

model User {
  id            String    @id @default(cuid())
  email         String    @unique
  name          String?
  image         String?
  createdAt     DateTime  @default(now())
  votes         Vote[]
  emailVerified DateTime?
  accounts      Account[]
  sessions      Session[]
}

model Poll {
  id        String    @id @default(cuid())
  text      String
  createdAt DateTime  @default(now())
  options Option[]
}

model Option {
  id          String   @id @default(cuid())
  answer      String
  createdAt   DateTime @default(now())
  poll    Poll @relation(fields: [pollId], references: [id])
  pollId  String
  votes       Vote[]
}

model Vote {
  id         String   @id @default(cuid())
  createdAt  DateTime @default(now())
  user       User     @relation(fields: [userId], references: [id])
  userId     String
  option   Option @relation(fields: [optionId], references: [id])
  optionId String
}

Next, run the Prisma commands to push the schema state to the database, generate the Prisma client, and apply the migration:

npx prisma db push
npx prisma migrate dev --name init

Once the migration is complete, start the Next.js server and visit the application at http://localhost:3000:

npm run dev

If you encounter an error related to the database not being present, you can execute the following set of commands. These commands enter PostgreSQL, create a prisma user with the password prisma, and grant the user permissions to create a database:

psql -U postgres
CREATE USER prisma WITH PASSWORD 'prisma';
ALTER USER prisma CREATEDB;
\q

Next, we'll define the file that loads Prisma. Due to how Next.js operates, we need to define it once and only once, even though we'll utilize it multiple times. The following code generates the Prisma client, saves it to the global namespace in memory, and then loads it every time it's needed in the future:

lib/prisma.ts

import { PrismaClient } from '@prisma/client'

let prisma: PrismaClient

if (process.env.NODE_ENV === 'production') {
  prisma = new PrismaClient()
} else {
  if (!global.prisma) {
    global.prisma = new PrismaClient()
  }
  prisma = global.prisma
}

export default prisma

Now, create the NextAuth backend route. The [...nextauth] designation indicates that a variety of routes will be created under the /api/auth namespace. You can delete the hello.ts file that's already present.

pages/api/auth/[...nextauth].ts

import NextAuth from 'next-auth'
import GoogleProvider from 'next-auth/providers/google'
import { PrismaAdapter } from '@next-auth/prisma-adapter'
import prisma from '@/lib/prisma'

export default NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),
  ],
})

Now, let's set up the GraphQL API that will utilize the Prisma library we just installed. First, create the graphql.ts file and add the following imports:

pages/api/graphql.ts

import { createYoga } from 'graphql-yoga';
import SchemaBuilder from '@pothos/core';
import PrismaPlugin from '@pothos/plugin-prisma';
import type PrismaTypes from '@pothos/plugin-prisma/generated';
import type { NextApiRequest, NextApiResponse } from 'next';
import { getServerSession } from 'next-auth/next';

import prisma from '../../lib/prisma';
import { authOptions } from './auth/[...nextauth]';
import { User } from '@prisma/client';

Next, configure the graphql-yoga server as shown in the code block below. We define a Context type to store the current user's information and create a createContext function to get the current user's session and fetch the user from the database using Prisma.

We instantiate the schema builder along with the default query and mutations. After defining the schema in the next block, we convert the builder into a schema that we can pass into graphql-yoga using the builder.toSchema() function call.

Update the pages/api/graphql.ts file:

// imports above

type Context = {
  currentUser: User | null
}

async function createContext({
  req,
  res,
}: {
  req: NextApiRequest
  res: NextApiResponse
}) {
  const session = await getServerSession(req, res, authOptions)

  let currentUser = null
  if (session?.user?.email) {
    currentUser = await prisma.user.findUnique({
      where: { email: session.user.email },
    })
  }

  return { currentUser }
}

const builder = new SchemaBuilder<{ PrismaTypes: PrismaTypes }>({
  plugins: [PrismaPlugin],
  prisma: {
    client: prisma,
  },
})

builder.queryType({})
builder.mutationType({})

const schema = builder.toSchema()

export default createYoga<{
  req: NextApiRequest
  res: NextApiResponse
}>({
  schema,
  graphqlEndpoint: '/api/graphql',
  context: createContext,
})

export const config = {
  api: {
    bodyParser: false,
  },
}

Now go to http://localhost:3000/api/graphql and you'll see a nice UI for executing queries without even having a UI. Since we haven't defined our schemas, mutations or resolvers you should see that the webpage loads but the query is complaining that we need to define our mutations and queries. Once you confirm the API is working, let's create our schema.

We call the builder.prismaObject for each of the models: User, Poll, Option, and Vote. Creating a separate graphQL endpoint allows us to decouple the raw CRUD operations that Prisma provides with what we actually want to expose to the frontend client. This allows us to lock down what's allowed to only the operations we want to allow.

With field-level control on the models, we can expose the fields we want and also create new fields that Prisma doesn't have but we still want the user to access. The Options model has a currentUserVoted and voteCount property, both of which are calculated. The currentUserVoted field is calculated by examining the user's object and checking if a Vote exists where the optionId matches the current option and the userId matches the user. The voteCount field is calculated by counting the number of votes with an optionId that matches the option.

pages/api/graphql.ts

# builder.queryType({}); we already have this
# builder.mutationType({}); we already have this line too

builder.prismaObject('User', {
  fields: t => ({
    id: t.exposeID('id'),
    email: t.exposeString('email'),
    name: t.exposeString('name', { nullable: true }),
    createdAt: t.field({
      type: 'String',
      resolve: user => user.createdAt.toISOString(),
    }),
    votes: t.relation('votes'),
  }),
});

builder.prismaObject('Poll', {
  fields: t => ({
    id: t.exposeID('id'),
    text: t.exposeString('text'),
    createdAt: t.field({
      type: 'String',
      resolve: user => user.createdAt.toISOString(),
    }),
    options: t.relation('options'),
  }),
});

builder.prismaObject('Option', {
  fields: t => ({
    id: t.exposeID('id'),
    answer: t.exposeString('answer'),
    createdAt: t.field({
      type: 'String',
      resolve: user => user.createdAt.toISOString(),
    }),
    poll: t.relation('poll'),
    pollId: t.exposeID('pollId'),
    votes: t.relation('votes'),
    currentUserVoted: t.field({
      type: 'Boolean',
      resolve: async (parent, _args, context) => {
        const ctx = context as Context;
        if (ctx?.currentUser) {
          const votes = await prisma.vote.findMany({
            where: { userId: ctx.currentUser.id, optionId: parent.id },
          });
          return votes.length > 0;
        }
        return false;
      },
    }),
    voteCount: t.field({
      type: 'Int',
      resolve: async (parent, _args, _context) => {
        const votes = await prisma.vote.findMany({
          where: { optionId: parent.id },
        });
        return votes.length;
      },
    }),
  }),
});

builder.prismaObject('Vote', {
  fields: t => ({
    id: t.exposeID('id'),
    createdAt: t.field({
      type: 'String',
      resolve: user => user.createdAt.toISOString(),
    }),
    user: t.relation('user'),
    userId: t.exposeID('userId'),
    option: t.relation('option'),
    optionId: t.exposeID('optionId'),
  }),
});

builder.queryField('polls', t =>
  t.prismaField({
    type: ['Poll'],
    resolve: async (query, _parent, _args) => prisma.poll.findMany({ ...query }),
  })
);

builder.mutationField('createPoll', t =>
  t.prismaField({
    type: ['Poll'],
    args: {
      text: t.arg.string({ required: true }),
      options: t.arg.stringList({ required: true }),
    },
    resolve: async (query, _parent, args) => {
      const { text, options } = args;

      const poll = await prisma.poll.create({
        data: {
          text,
          options: {
            createMany: {
              data: options.map(option => ({ answer: option })),
            },
          },
        },
        include: {
          options: true,
        },
      });

      return [poll];
    },
  })
);

builder.mutationField('castVote', t =>
  t.prismaField({
    type: ['Vote'],
    args: {
      optionId: t.arg.string({ required: true }),
    },
    resolve: async (_query, _parent, args, context) => {
      const ctx = context as Context;
      if (ctx?.currentUser?.id) {
        const { optionId } = args;
        const vote = await prisma.vote.create({
          data: { optionId, userId: ctx.currentUser.id },
        });

        return vote ? [vote] : [];
      }
      return [];
    },
  })
);

After defining all the prismaObjects we can define the votes query. This Pothos library makes defining queries easy, we can just pass in a type and then specify the resolver. We can use prisma to return all the polls. That covers the query we need, and the two mutations we need are createPoll and castVote. We can utilize nice nested creates with the createPoll mutation so that we can create the poll and then nested options. We pass in text for the poll and then an array of option strings. For the castVote mutation, we verify the person making the request is a valid user, and then we create a vote record where we specify the optionId and the userId.

With that we have a fully functioning API. Let's try it out by going back to that graphQL UI. Fire off the following mutation:

mutation {
  createPoll(text: "What's your favorite time of day?", options: ["Morning", "Evening"] ){
    id
    text
    options{
      id
      answer
    }
  }
}

You should see a successful response on the right pane. Now we can run a polls query and verify a proper response:

{
  polls{
    id
    text
    options{
      id
      answer
    }
  }
}

Testing out the castVote mutation will be trickier because we are confining it to valid users, and when we fire off the mutation in the graphQL UI, there is no set user. We can come back to test this after we create a UI for signing in within our Next.js app.

Next.js Frontend

Now that we have your backend set up, let's start with the frontend. First let's create the apollo-server config.

src/lib/apollo-client.ts

import { ApolloClient, InMemoryCache } from '@apollo/client'

const client = new ApolloClient({
  uri: '/api/graphql',
  cache: new InMemoryCache(),
})

export default client

Let's also set up the gql-calls.ts, these roughly match the queries and mutations we used for testing the API. One piece that's altered are the variables that we will pass in using the apollo client.

src/lib/gql-calls.ts

import { gql } from '@apollo/client'

export const CREATE_POLL = gql`
  mutation CreatePoll($text: String!, $options: [String!]!) {
    createPoll(text: $text, options: $options) {
      id
      text
      options {
        id
        answer
        voteCount
        currentUserVoted
      }
    }
  }
`

export const CAST_VOTE = gql`
  mutation CastVote($optionId: String!) {
    castVote(optionId: $optionId) {
      id
    }
  }
`

export const POLLS = gql`
  query {
    polls {
      id
      text
      createdAt
      options {
        id
        answer
        createdAt
        pollId
        currentUserVoted
        voteCount
      }
    }
  }
`

Now let's use this along with the next-auth config in our _app.tsx file.

src/pages/_app.tsx

import '@/styles/globals.css';

import type { AppProps } from 'next/app';
import { SessionProvider } from 'next-auth/react';
import { ApolloProvider } from '@apollo/client';
import client from '../lib/apollo-client';

export default function App({ Component, pageProps: { session, ...pageProps } }: AppProps) {
  return (
    <ApolloProvider client={client}>
      <SessionProvider session={session}>
        <Component {...pageProps} />
      </SessionProvider>
    </ApolloProvider>
  );
}

Let's set up tailwind and clean up our index.tsx file. First go to the public folder and delete everything except the favicon.ico file. Delete the styles/Home.module.css file and replace the styles/global.css with the following:

styles.global.css

@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@import 'byod-ui/dist/main.css';

Then initialize tailwind by running:

npx tailwindcss init

Now let's update the index.tsx file. We will utilize the byod-ui library for the first time for a login button and we can use the next-auth package for managing the session.

pages/index.tsx

import { useSession, signIn } from 'next-auth/react'
import { Login, LoginClicked } from 'byod-ui'

export default function Home() {
  const { status } = useSession()

  const signInClicked: LoginClicked = () => {
    signIn()
  }

  return (
    <div>
      {status === 'loading' ||
        (status === 'unauthenticated' && (
          <Login>
            <button
              onClick={() => signInClicked()}
              className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
            >
              Sign in
            </button>
          </Login>
        ))}
    </div>
  )
}

When you visit http://localhost:3000 you probably will see an error about the byod-ui library not being present. We need to install that local library

 npm install ../byod-ui
 cd ../byod-ui
 npm run build

Now you should see the login button. Press it and try to authenticate with google. If everything goes well, once you login you'll see a white screen because we have the useSession and we will only display the login component when the user is not logged in.

Now let's add a query! We can add the useQuery hook and the PollList react component with the resulting data.

pages/index.tsx

import { useSession, signIn } from 'next-auth/react'
import {
  CreatePoll,
  Login,
  LoginClicked,
  OnOptionClick,
  OnPollCreated,
  PollList,
} from 'byod-ui'
import { useCallback } from 'react'
import { useQuery } from '@apollo/client'
import { POLLS } from '@/lib/gql-calls'

export default function Home() {
  const { status } = useSession()
  const {
    data: pollsData,
    loading: pollsLoading,
    error: pollsError,
  } = useQuery(POLLS)

  const handleCreatePoll: OnPollCreated = useCallback(
    async (pollText, options) => {
      console.log('Poll created:', pollText, options)
    },
    []
  )

  const handleOptionClick: OnOptionClick = useCallback(
    async (pollId, optionId) => {
      console.log('Vote cast:', pollId, optionId)
    },
    []
  )

  const signInClicked: LoginClicked = () => {
    signIn()
  }

  return (
    <div>
      {status === 'loading' ||
        (status === 'unauthenticated' && (
          <Login>
            <button
              onClick={() => signInClicked()}
              className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
            >
              Sign in
            </button>
          </Login>
        ))}
      {status === 'authenticated' && (
        <>
          <PollList
            polls={pollsData?.polls || []}
            loading={pollsLoading}
            error={pollsError ? true : false}
            onOptionClick={handleOptionClick}
          />
          <CreatePoll onPollCreated={handleCreatePoll} />
        </>
      )}
    </div>
  )
}

Look how much progress we've made! You should now see our first poll with options as well as the create poll component. Try out both components by clicking on an option from the poll we made and checking out the console.logs in your browser, and you should see the vote log show both the userId and optionId. Then, the second log will be from when you filled out a poll with answers:

Vote cast: clgjzahzb0000tv9k8uz5m4sz clgjzahzd0001tv9kutmo7lqs
Poll created: What's the best ice cream flavor? (2) ['Mint chocolate chip', 'Moose tracks']

From here, we can go ahead and hook up the actual mutations. The create poll mutation gets utilized in the handleCreatePoll function which gets passed into the Create Poll react component and gets executed when the create button is pressed. We instantiate both mutations as hooks and then we can execute them within the callback function. We pass in both the pollText and options array that we get from the callback. After the mutation has successfully run, we use refetchQueries to run the polls query again to get the updated data. Optimistic queries that are run as the mutation goes out could be a way to get updated data even faster into the UI, but we need the refetchQueries so we can make sure we are staying synced with new polls that other users are doing. It should be noted that this update mechanism is not at all equivalent to the realtime updates we see in Twitter, but the app will still generally stay in sync as the user is creating and voting on new polls. When a user creates a new poll, the fields clear in the create poll component and its ready for the next poll to be created.

We also have the handleOptionClick callback function that gets executed when someone votes for an option on a poll. Once the person votes, we will see the poll show the results and the person can see how may people voted for each option without being biased before they vote.

pages/index.tsx

import { useSession, signIn } from 'next-auth/react'
import {
  CreatePoll,
  Login,
  LoginClicked,
  OnOptionClick,
  OnPollCreated,
  PollList,
} from 'byod-ui'
import { useCallback } from 'react'
import { useMutation, useQuery } from '@apollo/client'
import { CAST_VOTE, CREATE_POLL, POLLS } from '@/lib/gql-calls'

export default function Home() {
  const { status } = useSession()
  const {
    data: pollsData,
    loading: pollsLoading,
    error: pollsError,
  } = useQuery(POLLS)
  const [createPoll] = useMutation(CREATE_POLL, { refetchQueries: [POLLS] })
  const [castVote] = useMutation(CAST_VOTE, { refetchQueries: [POLLS] })

  const handleCreatePoll: OnPollCreated = useCallback(
    async (pollText, options) => {
      console.log('Poll created:', pollText, options)
      try {
        const response = await createPoll({
          variables: {
            text: pollText,
            options,
          },
        })

        console.log('Poll created:', response.data.createPoll)
      } catch (err) {
        console.error('Error creating poll:', err)
      }
    },
    [createPoll]
  )

  const handleOptionClick: OnOptionClick = useCallback(
    async (pollId, optionId) => {
      console.log('Vote cast:', pollId, optionId)
      try {
        const response = await castVote({
          variables: {
            optionId,
          },
        })

        console.log('Vote cast:', response.data.voteCast)
      } catch (err) {
        console.error('Error casting vote:', err)
      }
    },
    [castVote]
  )

  const signInClicked: LoginClicked = () => {
    signIn()
  }

  return (
    <div>
      {status === 'loading' ||
        (status === 'unauthenticated' && (
          <Login>
            <button
              onClick={() => signInClicked()}
              className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
            >
              Sign in
            </button>
          </Login>
        ))}
      {status === 'authenticated' && (
        <>
          <PollList
            polls={pollsData?.polls || []}
            loading={pollsLoading}
            error={pollsError ? true : false}
            onOptionClick={handleOptionClick}
          />
          <CreatePoll onPollCreated={handleCreatePoll} />
        </>
      )}
    </div>
  )
}

And that's it! You should have a fully functionality Next.js app that utilizes Prisma as its data source with Postgres. We have Next-Auth and Google OAuth provider for our authentication, an API route GraphQL server using GraphQL Yoga and Pothos schema generation. We will leave this app here and in the next blog post we will go through how to set up your own React component library like the byod-ui so you can create a plug and play library for all your related apps.

You can check out the final project with the following commit sha:

git checkout -b prisma-nextjs-final 94c25fbf8b95daaa8be6ea6c48bf4a0289567b62

Learn something new? Share it with the world!

There is more where that came from!

Drop your email in the box below and we'll let you know when we publish new stuff. We respect your email privacy, we will never spam you and you can unsubscribe anytime.