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 prismaObject
s 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