Intro
In this tutorial, we'll make a poll app using NextJS, Prisma and MySQL. You can find the final code in the end of this article.
Prerequisites
You need some basic understanding of NextJS and TypeScript.
Getting Started
First, we clone our starter code
git clone https://github.com/Posandu/nextjs-starter.git .
Then, we install the dependencies
npm install
After some time, we can run the development server
npm run dev
You can now view the app at (http://localhost:3000)[http://localhost:3000] and you should see the following:
Database
We will be using a SQL database for this project. For running queries, we will be using Prisma. Prisma is an ORM that allows us to run queries simply. I will be using MySQL for this project, but you can use any SQL database you want.
So first, we need to install the Prisma CLI
npm install prisma --save-dev
Then, we need to initialize the Prisma CLI
npx prisma init
This will create a prisma
folder in the root directory. Inside this folder, we will find a schema.prisma
file. This file contains the schema of our database. We will be using the following schema for this project:
model Poll {
id Int @id @default(autoincrement())
title String
createdAt DateTime @default(now())
choices String
}
model Vote {
id Int @id @default(autoincrement())
pollId Int
choice String
}
That is, we have a Poll
model and a Vote
model. The Poll
model contains the title of the poll and the choices. The Vote
model contains the poll id, the choice, and the id of the vote.
Now set your data credentials in the .env
file.
DATABASE_URL="myqsl://USER:PASSWORD@HOST:PORT/DATABASE?schema=public"
After that, we need to migrate our database. This will create the tables in our database.
npx prisma migrate dev
Now the tables are created, we need to install the Prisma client
npm install @prisma/client
Now create a file utils/prisma.ts
and add the following code:
import { PrismaClient } from "@prisma/client";
// PrismaClient is attached to the `global` object in development to prevent
// exhausting your database connection limit.
//
// Learn more:
// https://pris.ly/d/help/next-js-best-practices
let prisma: PrismaClient;
if (process.env.NODE_ENV === "production") {
prisma = new PrismaClient();
} else {
// @ts-ignore
if (!global.prisma) {
// @ts-ignore
global.prisma = new PrismaClient();
}
// @ts-ignore
prisma = global.prisma;
}
export default prisma;
This will create a singleton instance of the Prisma client. This will prevent us from creating multiple instances of the Prisma client. Now if you go to index.tsx
and import the client, you should see autocompletion for the Prisma client.
UI
Let's create a header that we will use on all the pages. Create a file components/header/index.tsx
and add the following code:
import ColormodeToggle from "@/colormodeToggle";
import { Box, Button, Flex } from "@chakra-ui/react";
import Link from "next/link";
const Header = () => {
return (
<Flex mt={6}>
<Box>
<Link href="/">
<Button variant="ghost">Polls</Button>
</Link>
</Box>
<Box ml="auto">
<Link href="/createPoll">
<Button mr={4}>Create Poll</Button>
</Link>
<ColormodeToggle />
</Box>
</Flex>
);
};
export default Header;
The header will look like this:
Now let's create a page for creating a poll. Create a file pages/createPoll.tsx
and add the following code:
import {
Alert,
Button,
Container,
Flex,
Heading,
Input,
Stack,
StackItem,
} from "@chakra-ui/react";
import type { NextPage } from "next";
import Header from "@/header";
import { useState } from "react";
import { AddIcon, DeleteIcon } from "@chakra-ui/icons";
const Home: NextPage = () => {
const [title, setTitle] = useState("Do you like this poll?");
const [options, setOptions] = useState(["Yes", "No"]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const submit = () => {
setError("");
// Validate title
if (title.length < 5 || title.length > 60) {
setError("Title must be between 5 and 100 characters");
return;
}
// Validate options
if (options.some((option) => option.length < 1 || option.length > 60)) {
setError("Options must be between 1 and 30 characters");
return;
}
// ... TODO: Send to API
};
return (
<Container maxW="container.lg">
<Header />
<Container maxW="container.md" mt={6} shadow="lg" p={8} rounded="2xl">
<Heading mb={4}>Create Poll</Heading>
<Heading my={4} size="md">
Title (Max 60)
</Heading>
<Input
placeholder="Poll Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<Heading my={4} size="md">
Options (Minimum 2, Maximum 10)
</Heading>
<Stack spacing={4}>
{options.map((option, index) => (
<StackItem key={index}>
<Flex>
<Input
placeholder="Option"
value={option}
onChange={(e) => {
const newOptions = [...options];
newOptions[index] = e.target.value;
setOptions(newOptions);
}}
/>
<Stack direction="row" ml={2}>
<Button
onClick={() => {
const newOptions = [...options];
newOptions.splice(index, 1);
setOptions(newOptions);
}}
disabled={options.length <= 2}
colorScheme="red"
variant="ghost"
>
<DeleteIcon />
</Button>
<Button
onClick={() => {
const newOptions = [...options];
newOptions.push("");
setOptions(newOptions);
}}
disabled={
options.length >= 10 || index !== options.length - 1
}
colorScheme="green"
>
<AddIcon />
</Button>
</Stack>
</Flex>
</StackItem>
))}
</Stack>
{error && (
<Alert mt={4} status="error">
{error}
</Alert>
)}
<Button
mt={4}
colorScheme="blue"
onClick={submit}
isLoading={loading}
disabled={loading}
>
Create Poll
</Button>
</Container>
</Container>
);
};
export default Home;
This page also has validation for the title and options. The validation is pretty simple, but it's good enough for now.
Now let's create a page for viewing a poll. Create a file pages/poll/[id].tsx
and add the following code:
import Header from "@/header";
import {
Button,
Container,
Heading,
Progress,
Stack,
StackItem,
} from "@chakra-ui/react";
import type { NextPage } from "next";
import { useRouter } from "next/router";
import { useState } from "react";
const Home: NextPage = () => {
const router = useRouter();
const { id } = router.query;
const [pollData, setPollData] = useState({
title: "Sample Poll",
options: [
{
name: "Sample Option",
votes: 40,
},
{
name: "Sample Option 2",
votes: 60,
},
],
});
const [voted, setVoted] = useState(false);
const [loading, setLoading] = useState(false);
const totalVotes = pollData.options.reduce(
(acc, option) => acc + option.votes,
0
);
const maxIndex = pollData.options.reduce((acc, option, index) => {
if (option.votes > pollData.options[acc].votes) {
return index;
}
return acc;
}, 0);
const castVote = (index: number) => {
setLoading(true);
// TODO
setTimeout(() => {
setLoading(false);
setVoted(true);
}, 4000);
};
return (
<Container maxW="container.lg">
<Header />
<Container maxW="container.md" mt={6} shadow="lg" p={8} rounded="2xl">
<Heading my={4}>{pollData.title}</Heading>
{voted ? (
<Stack spacing={4} mb={4}>
{pollData.options.map((option, index) => (
<StackItem key={index} pos="relative">
<Progress
colorScheme={index === maxIndex ? "green" : "blue"}
value={option.votes}
height="8"
max={totalVotes}
/>
<Heading size="sm" pos="absolute" top="0" mt="1" ml="2">
{option.name}
</Heading>
</StackItem>
))}
</Stack>
) : (
<Stack spacing={2} mb={4}>
{pollData.options.map((option, index) => (
<Button
key={index}
onClick={() => castVote(index)}
isLoading={loading}
loadingText="Casting Vote"
disabled={loading}
>
{option.name}
</Button>
))}
</Stack>
)}
</Container>
</Container>
);
};
export default Home;
Now, we've done the frontend. Let's move on to the backend.
Backend
We will create 3 API endpoints for our backend. The first one will be for creating a poll. The second one will be for getting a poll. The third one will be for voting in a poll.
The first is for creating a poll. Create a file pages/api/createPoll.ts
and add the following code:
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "$/prisma";
type Data = {
message: string;
error: boolean;
data?: any;
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<Data>
) {
// Check if the request is a POST request
if (req.method !== "POST") {
res.status(200).json({ message: "Method not allowed", error: true });
return;
}
// Check if the parameters `title` and `options` are present
if (!req.body.title || !req.body.options) {
res.status(200).json({ message: "Missing parameters", error: true });
return;
}
const { title, options } = req.body;
// Check if the title is not empty
if (title.length < 5 || title.length > 60) {
res.status(200).json({
message: "Title must be between 5 and 60 characters",
error: true,
});
return;
}
// Check if the options are not empty
if (options.length < 2 || options.length > 10) {
res.status(200).json({
message: "You must provide between 2 and 10 options",
error: true,
});
return;
}
// Check if the options are not empty
if (
options.some((option: string) => option.length < 1 || option.length > 60)
) {
res.status(200).json({
message: "Options must be between 1 and 60 characters",
error: true,
});
return;
}
// Create the poll
const data = await prisma.poll.create({
data: {
title,
choices: options.join(","),
},
});
res.status(200).json({ message: "Poll created", error: false, data });
}
This endpoint will create a poll in the database. It will also return the poll ID. We will use this ID to get the poll data and to vote in the poll.
Now, quickly go to the createPoll.tsx
and replace the submit
function with the following code:
const submit = () => {
setError("");
// Validate title
if (title.length < 5 || title.length > 60) {
setError("Title must be between 5 and 100 characters");
return;
}
// Validate options
if (options.some((option) => option.length < 1 || option.length > 60)) {
setError("Options must be between 1 and 30 characters");
return;
}
type Data = {
message: string;
error: boolean;
data?: any;
};
// API
fetch("/api/createPoll", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title,
options,
}),
})
.then((res) => res.json())
.then((res) => {
const resp: Data = res as any as Data;
if (resp.error) {
setError(resp.message);
} else {
window.location.href = `/poll/${resp.data.id}`;
}
});
};
If you go to the /createPoll
page, you will see that the poll is created and you are redirected to the poll page.
So I make sure it was created by opening prisma studio.
npx prisma studio
Now, let's create the second endpoint. This endpoint will be for getting a poll. Create a file pages/api/getPoll.ts
and add the following code:
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "$/prisma";
type Data = {
message: string;
error: boolean;
data?: any;
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<Data>
) {
// Check if the request is a POST request
if (req.method !== "POST") {
res.status(200).json({ message: "Method not allowed", error: true });
return;
}
// Check if the parameter `id` is present
if (!req.body.id) {
res.status(200).json({ message: "Missing parameters", error: true });
return;
}
const { id } = req.body;
// Get the poll
const data = await prisma.poll.findUnique({
where: {
id: parseInt(id),
},
});
// Check if the poll exists
if (!data) {
res.status(200).json({ message: "Poll not found", error: true });
return;
}
res.status(200).json({ message: "Poll found", error: false, data });
}
After creating this endpoint, edit the poll/[id].tsx
file and add the following code inside the function
// ... imports
import { useEffect, useState } from "react";
useEffect(() => {
fetch(`/api/getPoll/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id,
}),
})
.then((res) => res.json())
.then((_data) => {
type Data = {
message: string;
error: boolean;
data?: any;
};
const data: Data = _data;
if (!data.error) {
const options = data.data.choices.split(",").map((choice: any) => {
return {
name: choice,
votes: 0,
};
});
setPollData({
...data.data,
options,
});
setLoading(false);
}
});
}, [id]);
This code will fetch the poll data from the database and set it to the pollData
state. If no poll is found, it will just keep the loading
state as true
. (Trolling the user 😂)
Now, the last step is to update the poll/[id].tsx
file to show the poll data. You can replace the whole file with the following code:
import Header from "@/header";
import {
Button,
Container,
Heading,
Progress,
Spinner,
Stack,
StackItem,
} from "@chakra-ui/react";
import type { NextPage } from "next";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
const Home: NextPage = () => {
const router = useRouter();
const id = router.query.id;
type Vote = {
choice: string;
votes: number;
};
type Poll = {
title: string;
choices: string[];
id: string;
};
const [poll, setPoll] = useState<Poll>({} as Poll);
const [votes, setVotes] = useState<Vote[]>([]);
const [loading, setLoading] = useState(true);
const [voted, setVoted] = useState(false);
const [totalVotes, setTotalVotes] = useState(0);
useEffect(() => {
if (document.cookie.includes("voted_" + id)) {
setVoted(true);
}
if (id) {
fetch(`/api/getPoll`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id }),
})
.then((res) => res.json())
.then(({ data }) => {
data.choices = data.choices.split(",");
setPoll(data);
setLoading(false);
const votes: Vote[] = [];
data.choices.forEach((choice: any) => {
const obj = {
choice,
votes: [...data.votes].filter((vote) => vote.choice === choice)
.length,
};
votes.push(obj);
});
setVotes(votes);
setTotalVotes(data.votes.length);
});
}
}, [id]);
function castVote(option: string) {
fetch(`/api/castVote`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ option, id: poll.id }),
})
.then((res) => res.json())
.then(({ data }) => {
const votes: Vote[] = [];
poll.choices.forEach((choice) => {
const obj = {
choice,
votes: [...data].filter((vote) => vote.choice === choice).length,
};
votes.push(obj);
});
setVotes(votes);
setVoted(true);
setTotalVotes(data.length);
document.cookie = "voted_" + id + "=true";
});
}
return (
<Container maxW="container.lg">
<Header />
<Container maxW="container.md" mt={6} shadow="lg" p={8} rounded="2xl">
{loading && <Spinner size="xl" />}
{!loading && (
<>
<Heading my={4}>{poll.title}</Heading>
{voted ? (
<Stack spacing={4} mb={4}>
{poll.choices.map((option, index) => (
<StackItem key={index} pos="relative">
<Progress
colorScheme={"blue"}
value={
votes.find((vote) => vote.choice === option)?.votes
}
height="8"
max={totalVotes}
/>
<Heading size="sm" pos="absolute" top="0" mt="1" ml="2">
{option} (
{votes.find((vote) => vote.choice === option)?.votes})
</Heading>
</StackItem>
))}
</Stack>
) : (
<Stack spacing={2} mb={4}>
{poll.choices.map((option) => (
<Button
key={option}
onClick={() => castVote(option)}
isLoading={loading}
loadingText="Casting Vote"
disabled={loading}
>
{option}
</Button>
))}
</Stack>
)}
</>
)}
</Container>
</Container>
);
};
export default Home;
Now save it and you're done!
Conclusion
In this tutorial, we learned how to create a full-stack poll app using Next.js, TypeScript, and MySQL. We learned how to create a Next.js app and how to connect the Next.js app to the database. You can also add more features to this app like user authentication, IP address tracking, and more. I hope you enjoyed this tutorial. If you have any questions, feel free to ask them in the comments section below. Thanks for reading!
You can also support me by following me on Twitter and GitHub.
Also code is available on GitHub.
Top comments (0)