In Part 1 of this tutorial series, we successfully created and customized our Strapi CMS backend to create polls and votes and connect those newly created records to the currently authenticated user. We also integrated Instant DB and created new vote records for the authenticated user using the Instant DB admin SDK.
In this part, we'll create the frontend part of our application with Next.js and integrate our Strapi 5 backend. We will also set up Instant DB Client to get real-time updates whenever a vote is submitted.
For reference purposes, here's the outline of this blog series:
We'll start a new Next.js app by running the following command:
npx create-next-app@latest
On installation, we'll see the following prompts:
npx create-next-app@latest
✔ What is your project named? … votes-client
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes
...
Now, we'll navigate to the newly created directory and install a few more packages:
npm install @instantdb/react zustand
Let's also install a few dev dependencies:
npm install -D prettier prettier-plugin-tailwindcss @tailwindcss/forms
Finally, let's set up Shadcn UI for reusable components we can copy and paste into our app:
npx shadcn@latest init
And follow the prompts:
Need to install the following packages:
shadcn@2.1.0
Ok to proceed? (y) y
✔ Preflight checks.
✔ Verifying framework. Found Next.js.
✔ Validating Tailwind CSS.
✔ Validating import alias.
✔ Which style would you like to use? › New York
✔ Which color would you like to use as the base color? › Zinc
✔ Would you like to use CSS variables for theming? … no / yes
✔ Writing components.json.
✔ Checking registry.
✔ Updating tailwind.config.ts
✔ Updating app/globals.css
✔ Installing dependencies.
✔ Created 1 file:
- lib/utils.ts
Success! Project initialization completed.
You may now add components.
Let's add a few components we'll be using in this project:
npx shadcn@latest add avatar drawer sonner
Now that we've installed what we need for this project, let's set it up.
Create a new .env
file and enter the URL to the Strapi API:
# .env
NEXT_PUBLIC_API=http://localhost:1337
Since this is a TypeScript project, we can create a file to keep all type definitions which we can then import into the components that require them.
Create a new file - ./types/index.ts
:
1// ./types/index.ts
2
3type User = {
4 id: number;
5 documentId: string;
6 username?: string;
7 email?: string;
8 provider?: string;
9 confirmed?: boolean;
10 blocked?: boolean;
11 createdAt?: string;
12 updatedAt?: string;
13 publishedAt?: string;
14 locale?: null;
15};
16type Option = {
17 id: number;
18 documentId: string;
19 value: string;
20 createdAt: string;
21 updatedAt: string;
22 publishedAt: string;
23 locale: null;
24};
25type Vote = {
26 id: number;
27 documentId: string;
28 createdAt: string;
29 updatedAt: string;
30 publishedAt: string;
31 locale: string | null;
32 option: Option;
33 user?: User;
34};
35type Poll = {
36 id: number;
37 documentId: string;
38 question: string;
39 createdAt: string;
40 updatedAt: string;
41 publishedAt: string;
42 locale: null;
43 options: Option[];
44 votes: Vote[];
45 user?: User;
46};
47type VoteData = {
48 id: number;
49 documentId: string;
50 createdAt: string;
51 updatedAt: string;
52 publishedAt: string;
53 locale: string | null;
54 option: Option;
55 poll: Poll;
56};
57type Meta = {
58 pagination: {
59 page: number;
60 pageSize: number;
61 pageCount: number;
62 total: number;
63 };
64};
65type PollsResponse = {
66 data: Poll[];
67 meta: Meta;
68};
69type PollResponse = {
70 data: Poll;
71};
72type OptionResponse = {
73 data: Option;
74};
75type VotesResponse = {
76 data: VoteData[];
77 meta: Meta;
78};
79type VoteResponse = {
80 data: VoteData;
81};
82type InstantDBVote = {
83 user: {
84 documentId: string;
85 username: string;
86 email: string;
87 };
88 poll: {
89 question: string;
90 documentId: string;
91 };
92 option: {
93 value: string;
94 documentId: string;
95 };
96 createdAt: string;
97};
98type InstantDBSchema = {
99 votes: InstantDBVote;
100};
101export type {
102 User,
103 Option,
104 Vote,
105 Poll,
106 Meta,
107 PollsResponse,
108 PollResponse,
109 OptionResponse,
110 VotesResponse,
111 VoteResponse,
112 VoteData,
113 InstantDBVote,
114 InstantDBSchema,
115};
Let's create a utility function that can make requests and handle error responses from the Strapi API. Create a new file - ./utils/restRequest.ts
:
1// ./utils/restRequest.ts
2// Define a generic type for options passed into the request
3
4type RestRequestOptions<B> = {
5 url: string; // URL endpoint to which the request is sent
6 method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; // HTTP method (default is "GET")
7 headers?: Record<string, string>; // Optional headers for the request
8 body?: B; // Optional body for the request, can be any type
9};
10// A generic fetch wrapper function that handles REST API requests
11async function restRequest<T, B = undefined>({
12 url,
13 method = "GET", // Default method is GET
14 headers = {}, // Default headers are an empty object
15 body, // Optional body for requests like POST or PUT
16}: RestRequestOptions<B>): Promise<T> {
17 try {
18 // Send a fetch request with the provided options
19 const response = await fetch(url, {
20 method, // Use the specified HTTP method
21 headers: {
22 "Content-Type": "application/json", // Set content type to JSON by default
23 ...headers, // Merge additional headers passed in
24 },
25 body: body ? JSON.stringify(body) : undefined, // Stringify the body if provided
26 });
27 // If the response is not successful, parse and throw an error
28 if (!response.ok) {
29 const errorData = await response.json(); // Parse the error response
30 console.log("🚨🚨🚨🚨 ~ error data:", errorData); // Log error details
31 throw new Error(
32 errorData?.message || errorData?.error?.message || response.statusText
33 ); // Throw the error with a message
34 }
35 // If the response status is 204 (No Content), return an empty object
36 if (response.status === 204) {
37 return {} as T;
38 }
39 // Parse the successful response as JSON and return it
40 const data = (await response.json()) as T;
41 return data;
42 } catch (error) {
43 console.error("Error in restRequest:", error); // Log any errors that occur during the request
44 throw error; // Rethrow the error to be handled by the caller
45 }
46}
47
48export default restRequest;
This utility function, restRequest
, is a generic wrapper for making HTTP requests with fetch
, supporting various HTTP methods (GET
, POST
, PUT
, DELETE
, PATCH
) and customizable headers and request bodies. It handles errors by checking the response.ok
status and throwing detailed error messages when the request fails. If the response is successful, it returns the parsed JSON data. If the response status is 204 (No Content), it returns an empty object.
Create a new file - ./store/useUserStore.ts
and enter the following:
1// ./store/useUserStore.ts
2
3import { User } from "@/types";
4import { create } from "zustand";
5// define the UserStore type
6type UserStore = {
7 user: User | null;
8 setUser: (user: User | null) => void;
9};
10// create the UserStore
11export const useUserStore = create<UserStore>((set) => ({
12 // initialize the user to null
13 user: null,
14 // define the setUser function to update the user
15 setUser: (user) => set({ user }),
16}));
Here, we set up a Zustand store for managing user data in a React application. The useUserStore
is created using zustand’s create function, which defines a store with two key properties:
user
: Initialized as null
, this holds the current user’s information. The type User | null
allows it to either be a User
object or null
if no user is logged in.setUser
: A function that updates the user state. It takes a User
object or null
as an argument and updates the user state using the set
function from zustand.First, we'll create an AuthForm
component to handle register and login. Create a new file - ./components/Auth/Form.tsx
:
1// ./components/Auth/Form.tsx
2// Import necessary modules and hooks
3"use client"; // Enables client-side rendering in a Next.js app
4import { useUserStore } from "@/store/useUserStore"; // Zustand store for managing user state
5import { Loader } from "lucide-react"; // Loader icon from the lucide-react library
6import Link from "next/link"; // Link component for navigation
7import { useRouter } from "next/navigation"; // Next.js hook for programmatic navigation
8import { useState } from "react"; // React hook for managing local component state
9import { toast } from "sonner"; // Toast notifications for showing feedback
10import { db } from "@/utils/instant"; // InstantDB client for interacting with the database
11import restRequest from "@/utils/restRequest"; // Utility function for making REST API requests
12import {
13 LoginBody,
14 LoginResponse,
15 RegisterBody,
16 RegsiterResponse,
17} from "@/types"; // Types for login and registration
18// Function to handle user login using the Strapi API
19const loginUser = async ({ identifier, password }: LoginBody) => {
20 try {
21 // Send login request to Strapi API
22 const data = await restRequest<LoginResponse, LoginBody>({
23 url: `${process.env.NEXT_PUBLIC_API}/api/auth/local`,
24 method: "POST",
25 headers: {
26 "Content-Type": "application/json",
27 },
28 body: {
29 identifier,
30 password,
31 },
32 });
33 return data;
34 } catch (error) {
35 // Log and throw any errors that occur
36 console.log("🚨🚨🚨🚨 ~ loginUser error:", error);
37 throw error;
38 }
39};
40// Function to handle user registration using the Strapi API
41const registerUser = async ({ username, email, password }: RegisterBody) => {
42 try {
43 // Send registration request to Strapi API
44 const data = await restRequest<RegsiterResponse, RegisterBody>({
45 url: `${process.env.NEXT_PUBLIC_API}/api/auth/local/register`,
46 method: "POST",
47 body: {
48 username,
49 email,
50 password,
51 },
52 headers: {
53 "Content-Type": "application/json",
54 },
55 });
56 return data;
57 } catch (error) {
58 // Log and throw any errors that occur
59 console.log("🚨🚨🚨🚨 ~ register error:", error);
60 throw error;
61 }
62};
63// AuthForm component to handle both login and registration
64const AuthForm: React.FC<{
65 type: "login" | "register"; // The form type: "login" or "register"
66}> = ({ type }) => {
67 const router = useRouter(); // Use Next.js router for navigation
68 const { setUser } = useUserStore(); // Zustand store function to set the user
69 // State variables for form fields and loading state
70 const [username, setUsername] = useState("");
71 const [email, setEmail] = useState("");
72 const [password, setPassword] = useState("");
73 const [loading, setLoading] = useState(false);
74 // Handle form submission for both login and registration
75 const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
76 e.preventDefault(); // Prevent the default form submission
77 if (type === "login") {
78 // Handle login case
79 return toast.promise(
80 loginUser({ identifier: email.trim(), password: password.trim() }),
81 {
82 loading: (() => {
83 setLoading(true); // Set loading state to true
84 return "Logging in..."; // Display loading message
85 })(),
86 success: (data) => {
87 // On success, store JWT, user info, and InstantDB token in localStorage
88 localStorage.setItem("token", data.jwt);
89 localStorage.setItem("user", JSON.stringify(data.user));
90 localStorage.setItem("instantDBToken", data.instantdbToken);
91 setUser(data.user); // Set user in Zustand store
92 db?.auth.signInWithToken(data.instantdbToken); // Authenticate with InstantDB
93 router.push("/"); // Redirect to the homepage
94 return "Logged in successfully";
95 },
96 error: (err) => {
97 // Handle errors and display error message
98 console.log("🚨🚨🚨🚨 ~ handleSubmit ~ err", err);
99 return err.message; // Display error in toast notification
100 },
101 finally: () => {
102 setLoading(false); // Reset loading state
103 },
104 }
105 );
106 }
107 if (type === "register") {
108 // Handle registration case
109 return toast.promise(
110 registerUser({
111 username: username.trim(),
112 email: email.trim(),
113 password: password.trim(),
114 }),
115 {
116 loading: (() => {
117 setLoading(true); // Set loading state to true
118 return "Registering..."; // Display loading message
119 })(),
120 success: (data) => {
121 // On success, store JWT, user info, and InstantDB token in localStorage
122 localStorage.setItem("token", data.jwt);
123 localStorage.setItem("user", JSON.stringify(data.user));
124 localStorage.setItem("instantDBToken", data.instantdbToken);
125 setUser(data.user); // Set user in Zustand store
126 db?.auth.signInWithToken(data.instantdbToken); // Authenticate with InstantDB
127 router.push("/"); // Redirect to the homepage
128 return "Registered successfully";
129 },
130 error: (err) => {
131 // Handle errors and display error message
132 console.log("🚨🚨🚨🚨 ~ handleSubmit ~ err", err);
133 return err.message; // Display error in toast notification
134 },
135 finally: () => {
136 setLoading(false); // Reset loading state
137 },
138 }
139 );
140 }
141 };
142 return (
143 <>
144 {/* Form for login or registration */}
145 <form
146 onSubmit={handleSubmit}
147 className="border border-zinc-200 bg-zinc-50 p-4 lg:p-6"
148 >
149 <div className="wrapper flex flex-col gap-8">
150 {/* Show username field only for registration */}
151 {type === "register" && (
152 <div className="form-control">
153 <label htmlFor="username">Username</label>
154 <input
155 type="text"
156 name="username"
157 id="username"
158 onChange={(e) => setUsername(e.target.value)}
159 value={username}
160 className="form-input"
161 required
162 />
163 </div>
164 )}
165 {/* Email input field */}
166 <div className="form-control">
167 <label htmlFor="email">Email</label>
168 <input
169 type="email"
170 name="email"
171 id="email"
172 onChange={(e) => setEmail(e.target.value)}
173 value={email}
174 className="form-input"
175 required
176 />
177 </div>
178 {/* Password input field */}
179 <div className="form-control">
180 <label htmlFor="password">Password</label>
181 <input
182 type="password"
183 name="password"
184 id="password"
185 onChange={(e) => setPassword(e.target.value)}
186 value={password}
187 className="form-input"
188 required
189 />
190 </div>
191 {/* Submit button */}
192 <div className="action-cont">
193 <button
194 disabled={!email.trim() || !password.trim() || loading}
195 className="btn"
196 type="submit"
197 >
198 {type === "login" ? "Login" : "Register"}
199 {loading && <Loader className="icon animate-spin" />}
200 </button>
201 </div>
202 </div>
203 </form>
204 {/* Display link to switch between login and registration */}
205 {type == "login" ? (
206 <p className="mt-6">
207 Don't have an account?{" "}
208 <Link className="underline" href="/register">
209 Create one
210 </Link>
211 </p>
212 ) : (
213 <p className="mt-6">
214 Already have an account?{" "}
215 <Link className="underline" href="/login">
216 Login
217 </Link>
218 </p>
219 )}
220 </>
221 );
222};
223export default AuthForm;
The AuthForm
component is designed to handle both user registration and login using the Strapi API.
Depending on the form type, either "login" or "register," the component collects user credentials (username, email, password), submits them to the Strapi API, and stores the returned JWT token and user information in the browser's localStorage
for session management. It also integrates with Instant DB for database authentication.
The form provides feedback via toast notifications to inform the user about the status of the process (loading, success, or error).
The site header component will show the site name/logo, some navigation links, and the user's username when logged in. Create a new file - ./components/Site/Header.tsx
:
1// ./components/Site/Header.tsx
2"use client"; // This enables client-side rendering for this component.
3import { useUserStore } from "@/store/useUserStore"; // Import the Zustand store to access user state.
4import Link from "next/link"; // Import Link from Next.js for navigation.
5import { useEffect } from "react"; // Import useEffect to run code on component mount.
6const SiteHeader = () => {
7 // Extract user state and setUser function from Zustand store
8 const { user, setUser } = useUserStore();
9 // Handles user logout by clearing localStorage and resetting the user state.
10 const handleLogout = async () => {
11 localStorage.removeItem("user"); // Remove user data from local storage.
12 localStorage.removeItem("token"); // Remove authentication token from local storage.
13 localStorage.removeItem("instantDBToken"); // Remove InstantDB token from local storage.
14 setUser(null); // Update the Zustand store to reflect no user is logged in.
15 window.location.reload(); // Refresh the page to update the UI.
16 };
17 useEffect(() => {
18 // On component mount, retrieve user from localStorage if present and set it in the store.
19 const user = localStorage.getItem("user");
20 if (user) {
21 setUser(JSON.parse(user)); // Parse the JSON string and set the user state in the store.
22 }
23 }, [setUser]); // Effect runs when component mounts, no dependencies.
24 return (
25 <header className="sticky top-0 w-full bg-white border-b border-b-zinc-200 p-4">
26 {/* Container for the site header */}
27 <div className="wrapper mx-auto flex w-full max-w-3xl items-center justify-between">
28 {/* Logo or site name that links back to the homepage */}
29 <Link href="/">
30 <figure>
31 <h1 className="text-lg font-bold">Votes.</h1>
32 </figure>
33 </Link>
34 {/* Navigation section */}
35 <nav className="site-nav">
36 <ul className="flex items-center gap-4">
37 {/* Conditional rendering based on user login status */}
38 {user ? (
39 // If user is logged in, display a welcome message and logout button
40 <>
41 <li>
42 <p className="truncate">
43 Welcome, <strong>{user.username}</strong>
44 </p>
45 </li>
46 <li>
47 <button onClick={handleLogout}>Logout</button>
48 </li>
49 </>
50 ) : (
51 // If user is not logged in, show login and register links
52 <>
53 <li>
54 <Link href="/login">Login</Link>
55 </li>
56 <li>
57 <Link href="/register">Register</Link>
58 </li>
59 </>
60 )}
61 </ul>
62 </nav>
63 </div>
64 </header>
65 );
66};
67export default SiteHeader;
Here, the SiteHeader
component is responsible for rendering the navigation header. It uses Zustand to manage user state via the useUserStore
hook.
When the component mounts, the useEffect
hook checks if a user is stored in localStorage
and updates the app's user state with setUser
. If a user is logged in, it displays a welcome message along with a "Logout" button; otherwise, it shows links to the login and registration pages.
The handleLogout
function clears user-related data from localStorage
, sets the user state to null
, and reloads the page to update the UI. Links for navigation are created using the Link
component from Next.js.
Now that we have created a few of our basic components, let's set up the app layout. In the ./app/layout.tsx
file:
1// ./app/layout.tsx
2// Import global CSS styles and necessary components
3import "./globals.css"; // Imports global CSS file for styling
4import { Toaster } from "@/components/ui/sonner"; // Imports a custom Toaster component for notifications
5import SiteHeader from "@/components/Site/Header"; // Imports the header component for the site
6import { Metadata } from "next"; // Type definition for page metadata in Next.js
7// Define metadata for the page, including title and description for SEO purposes
8export const metadata: Metadata = {
9 title: "Votes.", // Title of the webpage
10 description: "Express your opinion with Votes and polls.", // Brief description of the site
11};
12// Root layout component that wraps the entire application
13export default function RootLayout({
14 children, // Accepts children components to be rendered within the layout
15}: Readonly<{
16 children: React.ReactNode; // Defines the type for children, which are React components
17}>) {
18 return (
19 <html lang="en">
20 {" "}
21 {/* Sets the language attribute for the HTML document */}
22 <body>
23 <SiteHeader /> {/* Renders the SiteHeader component at the top */}
24 <Toaster richColors theme="system" className="bg-white" />{" "}
25 {/* Renders the Toaster for displaying notifications, using system theme */}
26 {children} {/* Renders any child components passed into the layout */}
27 </body>
28 </html>
29 );
30}
With that, we should have something like this:
Next, we'll create the authentication pages.
Create a new file - ./app/register/page.tsx
:
1// ./app/register/page.tsx
2
3// Import the AuthForm component which will handle the registration form
4import AuthForm from "@/components/Auth/Form";
5
6// Define a functional component for the Register page
7const RegisterPage = () => {
8 return (
9 <main> {/* The main content of the page */}
10 <header className="site-section"> {/* A header section for the page title */}
11 <div className="wrapper"> {/* Wrapper to contain and center the content */}
12 <h1 className="text-3xl lg:text-7xl">Register</h1> {/* Large heading for the page title, with responsive sizing */}
13 </div>
14 </header>
15
16 <section className="site-section"> {/* A section to contain the registration form */}
17 <div className="wrapper mx-auto max-w-3xl"> {/* Wrapper that centers the content and limits the width to 3xl */}
18 <AuthForm type="register" /> {/* Renders the AuthForm component with "register" as the type for the form */}
19 </div>
20 </section>
21 </main>
22 );
23};
24
25// Export the RegisterPage component to be used in the application
26export default RegisterPage;
With that, we should have something like this:
Let's see it in action:
Nice.
Similarly, create a new file - ./app/login/page.tsx
:
1// ./app/login/page.tsx
2// Import the AuthForm component which will handle the registration form
3import AuthForm from "@/components/Auth/Form";
4// Define a functional component for the Login page
5const LoginPage = () => {
6 return (
7 <main>
8 {/* The main content of the page */}
9 <header className="site-section">
10 {/* A header section for the page title */}
11 <div className="wrapper">
12 {/* Wrapper to contain and center the content */}
13 <h1 className="text-3xl lg:text-7xl">Login</h1>
14 {/* Large heading for the page title, with responsive sizing */}
15 </div>
16 </header>
17 <section className="site-section">
18 {/* A section to contain the registration form */}
19 <div className="wrapper mx-auto max-w-3xl">
20 {/* Wrapper that centers the content and limits the width to 3xl */}
21 <AuthForm type="login" />
22 {/* Renders the AuthForm component with "login" as the type for the form */}
23 </div>
24 </section>
25 </main>
26 );
27};
28// Export the LoginPage component to be used in the application
29export default LoginPage;
With that, we should have something like this:
Let's see it in action:
Awesome.
When you check the local storage from the devtools you should see that the user data, JWT and InstantDB token are all saved:
Next, we'll quickly set up InstantDB in our app.
The first step is to initialize Instant.
Add your App ID to your .env
:
NEXT_PUBLIC_API=http://localhost:1337
NEXT_PUBLIC_INSTANT_APP_ID=ee543ff6-c9cf-4a8b-9dc0-504994f3860a
Create a new file - ./utils/instant.ts
:
1import { init } from "@instantdb/react";
2import { InstantDBSchema } from "@/types";
3
4// ID for app: Voting App
5const APP_ID = process.env.NEXT_PUBLIC_INSTANT_APP_ID as string;
6
7export const db = init<InstantDBSchema>({ appId: APP_ID });
Now, we can import and call the db
function whenever we need it.
We'll create all the components we need for viewing and creating Polls.
Create a new file - ./components/Poll/Form.tsx
and enter the following code:
1// ./components/Poll/Form.tsx
2// Import necessary React hooks and libraries
3import { useState } from "react"; // React hook to manage component state
4import { toast } from "sonner"; // Importing toast notification from sonner
5import { OptionResponse, PollResponse } from "@/types"; // Type definitions for API responses
6import restRequest from "@/utils/restRequest"; // Utility function for making API requests
7import { useRouter } from "next/navigation"; // Router hook for navigation
8import { Loader, X } from "lucide-react"; // Icons for loading and remove option
9// Async function to create a poll, accepts question, options, and token as parameters
10const createPoll = async ({
11 question,
12 options,
13 token,
14}: {
15 question: string;
16 options: string[];
17 token: string;
18}) => {
19 try {
20 // API URLs for creating polls and options
21 const pollUrl = `${process.env.NEXT_PUBLIC_API}/api/polls?populate=*`;
22 const optionUrl = `${process.env.NEXT_PUBLIC_API}/api/options?populate=*`;
23 // Create options by making API requests for each one
24 const optionsResponses = await Promise.all(
25 options.map(async (option) => {
26 return await restRequest<OptionResponse, { data: { value: string } }>({
27 url: optionUrl,
28 method: "POST",
29 headers: {
30 Authorization: `Bearer ${token}`,
31 },
32 body: {
33 data: {
34 value: option, // The option text
35 },
36 },
37 });
38 })
39 );
40 // Create the poll by sending the question and option IDs
41 const pollResponse = await restRequest<
42 PollResponse,
43 { data: { question: string; options: string[] } }
44 >({
45 url: pollUrl,
46 method: "POST",
47 headers: {
48 Authorization: `Bearer ${token}`,
49 },
50 body: {
51 data: {
52 question, // Poll question
53 options: optionsResponses.map((response) => response.data.documentId), // Map the option IDs
54 },
55 },
56 });
57 return pollResponse; // Return the created poll response
58 } catch (error) {
59 console.log("🚨🚨🚨🚨 ~ createPoll ~ error", error); // Log any errors
60 throw error; // Rethrow the error to handle it in the calling function
61 }
62};
63// Component for rendering individual options with a remove button
64const OptionItem: React.FC<{
65 option: string;
66 updateRemoveOption: (option: string) => void; // Callback to remove the option
67}> = ({ option, updateRemoveOption }) => {
68 return (
69 <li className="inline-flex w-fit items-center justify-between border border-zinc-200 bg-zinc-100">
70 <span className="px-4">{option}</span> {/* Display option text */}
71 <button
72 className="btn h-full grow"
73 onClick={() => {
74 updateRemoveOption(option); // Trigger option removal when button is clicked
75 }}
76 >
77 <X className="icon" />{" "}
78 {/* Display the 'X' icon for removing the option */}
79 </button>
80 </li>
81 );
82};
83// Main PollForm component
84const PollForm: React.FC = ({}) => {
85 const router = useRouter(); // Hook to navigate between pages
86 const [question, setQuestion] = useState<string>(""); // State to manage the poll question
87 const [options, setOptions] = useState<string[]>([]); // State to manage poll options
88 const [option, setOption] = useState<string>(""); // State to manage the current option input
89 const [loading, setLoading] = useState<boolean>(false); // State to manage loading state
90 // Function to add the current option to the options list
91 const addOption = () => {
92 setOptions([...options, option]); // Add the current option to the options array
93 setOption(""); // Clear the input field
94 };
95 // Function to handle the creation of a poll
96 const handleCreatePoll = () => {
97 // Display a toast notification during the poll creation process
98 toast.promise(
99 createPoll({
100 question,
101 options,
102 token: localStorage.getItem("token") || "", // Retrieve the token from local storage
103 }),
104 {
105 loading: (() => {
106 setLoading(true); // Set loading state to true while the request is being processed
107 return "Creating poll..."; // Loading message
108 })(),
109 success: (data) => {
110 // Reset the form upon success
111 setQuestion(""); // Clear the question
112 setOptions([]); // Clear the options
113 setOption(""); // Clear the option input
114 router.push(`/poll/${data.data.documentId}`); // Redirect to the created poll page
115 return "Poll created!"; // Success message
116 },
117 error: (error) => {
118 return error.message; // Display error message in the toast
119 },
120 finally: () => {
121 setLoading(false); // Reset loading state after completion
122 },
123 }
124 );
125 };
126 return (
127 // Form for creating a poll
128 <form
129 onSubmit={(e) => {
130 e.preventDefault(); // Prevent default form submission
131 handleCreatePoll(); // Trigger poll creation
132 }}
133 className="border border-zinc-200 bg-zinc-50 p-4 lg:p-6"
134 >
135 <div className="wrapper flex flex-col gap-4">
136 <div className="form-control">
137 <label htmlFor="question">Question</label>{" "}
138 {/* Label for question input */}
139 <input
140 type="text"
141 id="question"
142 name="question"
143 value={question} // Bind input value to the question state
144 onChange={(e) => setQuestion(e.target.value)} // Update state when input changes
145 />
146 </div>
147 <div className="form-control">
148 <label htmlFor="option">Option</label> {/* Label for option input */}
149 <div className="flex">
150 <input
151 type="text"
152 id="option"
153 name="option"
154 value={option} // Bind input value to the option state
155 onChange={(e) => setOption(e.target.value)} // Update state when input changes
156 />
157 <button className="btn" type="button" onClick={addOption}>
158 Add
159 </button>{" "}
160 {/* Button to add the option to the list */}
161 </div>
162 <ul className="flex gap-2">
163 {/* Map through the options array to display each option */}
164 {options.map((option) => (
165 <OptionItem
166 key={option}
167 option={option} // Pass option text
168 updateRemoveOption={(option) => {
169 setOptions(options.filter((opt) => opt !== option)); // Remove option from the list
170 }}
171 />
172 ))}
173 </ul>
174 </div>
175 <div className="action-cont">
176 {/* Button to submit the form and create the poll */}
177 <button className="btn" type="submit" disabled={loading}>
178 Create Poll
179 {loading && <Loader className="icon animate-spin" />}{" "}
180 {/* Show loader while processing */}
181 </button>
182 </div>
183 </div>
184 </form>
185 );
186};
187export default PollForm; // Export the PollForm component
Here we have three main parts to the component:
1. createPoll
:
- This is an asynchronous function that handles the core logic for creating a poll. It first creates options by making separate API calls for each poll option, storing the resulting IDs. Then, it sends a final API request to create the poll itself, linking it to the generated options. This function also includes error handling for any issues during the API request process.
2. OptionItem
:
- This component renders each option in a list format. It displays the option's text and a remove button that, when clicked, triggers a callback function (updateRemoveOption
) to remove the option from the poll.
3. PollForm
:
- The main component that renders the form for creating a poll. It maintains the state for the poll question, current options, and handles form submission. The form also includes logic to display loading states, handle adding/removing options, and show notifications using the toast
library when creating a poll. This component ties everything together, managing user input and interactions.
This component will be used to show or hide the Poll Form. Create a new fil - ./components/Poll/Drawer.tsx
:
1// ./components/Poll/Drawer.tsx
2// Import necessary components from the drawer UI library
3import {
4 Drawer,
5 DrawerClose,
6 DrawerContent,
7 DrawerDescription,
8 DrawerFooter,
9 DrawerHeader,
10 DrawerTitle,
11 DrawerTrigger,
12} from "@/components/ui/drawer";
13import PollForm from "./Form";
14// The PollDrawer component is a wrapper that displays a drawer containing the poll creation form.
15const PollDrawer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
16 return (
17 // Main Drawer component that toggles the drawer UI
18 <Drawer>
19 {/* The trigger that opens the drawer, renders the passed child element */}
20 <DrawerTrigger asChild>
21 {children || <button>Open Drawer</button>}
22 </DrawerTrigger>
23 {/* The content inside the drawer */}
24 <DrawerContent className="bg-white">
25 {/* A wrapper to control the drawer's width and center it */}
26 <div className="wrapper mx-auto w-full max-w-3xl">
27 {/* Drawer header contains the title and description */}
28 <DrawerHeader>
29 <DrawerTitle>Create a new poll</DrawerTitle>
30 <DrawerDescription>
31 Fill in the form below to create a new poll.
32 </DrawerDescription>
33 </DrawerHeader>
34 {/* Drawer body contains the poll creation form */}
35 <div className="p-4">
36 <PollForm />
37 </div>
38 {/* Drawer footer with a cancel button that closes the drawer */}
39 <DrawerFooter>
40 <DrawerClose asChild>
41 <button className="btn w-full grow">Cancel</button>
42 </DrawerClose>
43 </DrawerFooter>
44 </div>
45 </DrawerContent>
46 </Drawer>
47 );
48};
49export default PollDrawer;
DrawerTrigger
: This element wraps around the child component to open the drawer when clicked.DrawerContent
: Contains the content displayed in the drawer (header, form, and footer).DrawerHeader
: Provides a title and description for the drawer.PollForm
: The form for creating a poll is embedded inside the drawer.DrawerFooter
: Contains a button to close the drawer using DrawerClose.Now that we have the form and the drawer for displaying the form ready, let's create the home page.
Create a new file - ./components/Home/Page.tsx
:
1// ./components/Home/Page.tsx
2// Use client-side rendering
3"use client";
4import PollDrawer from "@/components/Poll/Drawer"; // Import the PollDrawer component for creating new polls
5import { PollsResponse } from "@/types"; // Import the PollsResponse type for typing the polls data
6import restRequest from "@/utils/restRequest"; // Import the utility function for making API requests
7import { Plus } from "lucide-react"; // Import the Plus icon from Lucide
8import { useEffect, useState } from "react"; // Import React hooks for managing state and side effects
9// Function to fetch polls from the API
10const getPolls = async ({ token }: { token: string }) => {
11 // Construct the API URL for fetching polls with related data
12 const pollsUrl = `${process.env.NEXT_PUBLIC_API}/api/polls?populate\[votes\][populate][0]=option&populate[options]=*`;
13 // Make a request to the API to fetch polls
14 const pollsResponse = await restRequest<PollsResponse>({
15 url: pollsUrl,
16 headers: {
17 Authorization: `Bearer ${token}`, // Set the Authorization header with the token
18 },
19 });
20 return pollsResponse; // Return the polls data
21};
22const HomePage = () => {
23 const [polls, setPolls] = useState<PollsResponse | null>(null); // State to hold the fetched polls data
24 const [loading, setLoading] = useState<boolean>(true); // State to track loading status
25 // Function to handle fetching polls with error handling
26 const handleGetPolls = async (token: string) => {
27 try {
28 const data = await getPolls({ token }); // Call the getPolls function
29 setPolls(data); // Set the fetched polls data to state
30 setLoading(false); // Set loading to false after fetching
31 } catch (error) {
32 console.log("🚨🚨🚨🚨 ~ handleGetPolls ~ error", error); // Log any errors that occur
33 } finally {
34 setLoading(false); // Ensure loading is false in the end
35 }
36 };
37 // useEffect to fetch polls when the component mounts
38 useEffect(() => {
39 const token = localStorage.getItem("token"); // Retrieve the token from local storage
40 if (!token) {
41 return setLoading(false); // If no token, set loading to false
42 }
43 handleGetPolls(token); // Fetch polls if token is available
44 }, []);
45 return (
46 <main>
47 <header className="site-section">
48 <div className="wrapper flex w-full items-center justify-between gap-6">
49 <h1 className="text-4xl lg:text-7xl">Polls</h1> {/* Page title */}
50 <PollDrawer>
51 <button className="btn max-lg:pl-2">
52 {" "}
53 {/* Button to open the poll creation drawer */}
54 <span className="max-lg:hidden">Create a new poll</span>
55 <Plus className="icon" /> {/* Plus icon */}
56 </button>
57 </PollDrawer>
58 </div>
59 </header>
60 <section className="site-section">
61 <div className="wrapper">
62 {loading ? ( // Conditional rendering based on loading state
63 <p>Loading...</p>
64 ) : polls ? ( // If polls data is available
65 <p>{polls.data.length} polls found</p> // Display the number of polls found
66 ) : (
67 <p>No polls found. Make sure you are logged in.</p> // Message if no polls are found
68 )}
69 </div>
70 </section>
71 </main>
72 );
73};
74export default HomePage;
Here, we're using the getPolls
function to fetch polls from our API.
The getPolls
function makes a request with an authorization token included in the headers. If the request is successful, it returns the poll data; if it fails, it logs the error for debugging.
Then, in the ./app/page.tsx
file, enter the following:
1// ./app/page.tsx
2import HomePage from "@/components/Home/Page";
3export default function Home() {
4 return <HomePage />;
5}
With that, we should have something like this:
Great.
If we click on the Create a new poll + button we should see the form displayed in the drawer:
Next, we'll have to display the actual polls. To do that, we'll have to create a few poll-specific helper functions first.
Create a new file - ./utils/poll/index.ts
:
1// ./utils/poll/index.ts
2import { InstantDBVote, Vote, VoteResponse } from "@/types"; // Import types for votes
3import restRequest from "@/utils/restRequest"; // Import the utility for making REST requests
4// Function to create a vote for a specific poll option
5const createVote = async ({
6 poll, // Poll ID to which the vote is associated
7 option, // Option ID that is being voted for
8 token, // Authorization token for the API request
9}: {
10 poll: string;
11 option: string;
12 token: string;
13}) => {
14 // Define the API endpoint for creating votes
15 const voteUrl = `${process.env.NEXT_PUBLIC_API}/api/votes?populate=*`;
16 // Make the REST request to create a vote
17 const voteResponse = await restRequest<
18 VoteResponse,
19 {
20 data: {
21 option: string;
22 poll: string;
23 };
24 }
25 >({
26 url: voteUrl, // URL of the API endpoint
27 method: "POST", // HTTP method for creating a new resource
28 headers: {
29 Authorization: `Bearer ${token}`, // Include authorization token in headers
30 },
31 body: {
32 data: {
33 poll, // Include the poll ID in the request body
34 option, // Include the option ID in the request body
35 },
36 },
37 });
38 return voteResponse; // Return the response from the vote creation
39};
40// Combine votes from the poll and real-time votes from InstantDB
41const mergeVotes = (
42 pollVotes: Vote[], // Existing votes from the poll
43 realTimeVotes: InstantDBVote[] // Real-time votes to be merged
44): Vote[] => {
45 const mergedVotes = [...pollVotes]; // Start with existing poll votes
46 // Convert realTimeVotes into Vote objects and add them if they don't exist
47 realTimeVotes.forEach((instantVote) => {
48 const existsInPoll = pollVotes.some(
49 (pollVote) =>
50 pollVote.option.documentId === instantVote.option.documentId &&
51 pollVote.user?.documentId === instantVote.user.documentId
52 );
53 // If the real-time vote does not already exist in pollVotes, transform and add it
54 if (!existsInPoll) {
55 mergedVotes.push({
56 id: Math.random(), // Placeholder ID since real-time votes won't have it
57 documentId: instantVote.poll.documentId,
58 createdAt: instantVote.createdAt,
59 updatedAt: instantVote.createdAt,
60 publishedAt: instantVote.createdAt,
61 locale: null,
62 option: {
63 documentId: instantVote.option.documentId,
64 value: instantVote.option.value,
65 id: Math.random(), // Placeholder ID for option
66 createdAt: instantVote.createdAt,
67 updatedAt: instantVote.createdAt,
68 publishedAt: instantVote.createdAt,
69 locale: null,
70 },
71 user: {
72 documentId: instantVote.user.documentId,
73 username: instantVote.user.username,
74 email: instantVote.user.email,
75 id: Math.random(), // Placeholder ID for user
76 },
77 });
78 }
79 });
80 return mergedVotes; // Return the combined list of votes
81};
82export { createVote, mergeVotes }; // Export the functions for external use
Here, The code defines two main functionalities:
1. createVote
Function: This asynchronous function handles the creation of a new vote. It constructs a URL for the API endpoint responsible for managing votes and sends a POST request that includes the poll ID, option ID, and authorization token in the headers.
2. mergeVotes
Function: This function takes two parameters: existing poll votes and real-time votes from our InstantDB setup.
It iterates over the real-time votes and checks if they already exist in the existing poll votes. If a real-time vote does not exist, it transforms the vote into the format expected by the application and adds it to the list of merged votes. Finally, it returns the updated list that combines both sources of votes.
With this, we can effectively manage votes, ensuring that users can see a comprehensive view of all votes, including those cast in real-time and those saved in the poll's database.
To display the polls, we'll create the Poll card component which will display the poll, and options.
Create a new file - ./components/Poll/Card.tsx
:
1// ./components/Poll/Card.tsx
2// Importing necessary libraries and types
3import { useUserStore } from "@/store/useUserStore";
4import { InstantDBVote, Option, Poll, Vote } from "@/types";
5import { createVote, mergeVotes } from "@/utils/poll";
6import { Check, Loader, VoteIcon } from "lucide-react";
7import Link from "next/link";
8import { useMemo, useState } from "react";
9import { toast } from "sonner";
10// Component for displaying an option in the poll with a progress bar for votes
11const OptionBar: React.FC<{
12 option: Option; // The option object containing option details
13 votes?: Vote[]; // Array of votes for this option
14 votesCount: number; // Count of votes for this option
15 totalVotes: number; // Total votes for all options
16 onOptionClick?: (option: string) => void; // Function to call when the option is clicked
17 loading?: { [key: string]: boolean }; // Loading states for options
18}> = ({ option, votesCount, votes, totalVotes, onOptionClick, loading }) => {
19 const { user } = useUserStore(); // Get user details from the store
20 // Check if the user has voted for this option
21 const userVotedForOption = votes?.find(
22 (vote) =>
23 vote.user?.documentId === user?.documentId &&
24 vote.option.documentId === option.documentId
25 );
26 return (
27 <div className="flex gap-2">
28 <div className="relative flex w-full items-center justify-between">
29 <div className="relative flex w-full items-center justify-between border border-zinc-200">
30 {/* Progress bar representing the percentage of votes */}
31 <div
32 className="absolute h-full bg-zinc-300 transition-all"
33 style={{ width: `${(votesCount / totalVotes) * 100}%` }} // Calculate width based on votes
34 ></div>
35 <span className="relative p-2 text-zinc-900">{option.value}</span>
36 <span className="right-0 z-10 p-2 px-4">{votesCount}</span>
37 </div>
38 </div>
39 {/* Button for voting */}
40 <button
41 onClick={() => {
42 if (onOptionClick) {
43 onOptionClick(option.documentId); // Call the option click handler with option ID
44 }
45 }}
46 className="btn shrink-0"
47 disabled={loading?.[option.documentId] || !!userVotedForOption} // Disable if loading or user has voted
48 >
49 {/* Display different icons based on voting state */}
50 {!loading?.[option.documentId] ? (
51 !userVotedForOption ? (
52 <VoteIcon className="icon shrink-0" /> // Voting icon
53 ) : (
54 <Check className="icon shrink-0" /> // Checkmark icon if voted
55 )
56 ) : (
57 <Loader className="icon shrink-0 animate-spin" /> // Loader icon if loading
58 )}
59 </button>
60 </div>
61 );
62};
63// Main component for displaying the poll card
64const PollCard: React.FC<{
65 poll: Poll; // The poll object containing poll details
66 votes: InstantDBVote[]; // Array of real-time votes
67 type?: "small" | "large"; // Type of poll card for styling
68}> = ({ poll, votes, type = "small" }) => {
69 const [loading, setLoading] = useState<{ [key: string]: boolean }>(); // Loading state for options
70 // Memoize merged votes to avoid recalculating on each render
71 const mergedVotes = useMemo(
72 () => mergeVotes(poll.votes, votes), // Merge existing poll votes with real-time votes
73 [poll.votes, votes]
74 );
75 // Handle voting option click
76 const handleOptionClick = (option: string) => {
77 toast.promise(
78 createVote({
79 poll: poll.documentId, // Poll ID
80 option, // Selected option ID
81 token: localStorage.getItem("token") || "", // Get token from local storage
82 }),
83 {
84 loading: (() => {
85 setLoading({
86 ...loading,
87 [option]: true, // Set loading state for the clicked option
88 });
89 return "Voting..."; // Loading message
90 })(),
91 success: () => {
92 return "Voted successfully!"; // Success message
93 },
94 error: (error) => {
95 console.log("🚨🚨🚨🚨 ~ handleOptionClick ~ error", error); // Log error
96 return error.message; // Return error message
97 },
98 finally: () => {
99 setLoading({
100 ...loading,
101 [option]: false, // Reset loading state for the clicked option
102 });
103 },
104 }
105 );
106 };
107 return (
108 <article
109 className={`poll-card h-full ${type == "small" ? "border border-zinc-200 bg-zinc-50" : ""}`}
110 >
111 <div
112 className={`wrapper flex h-full flex-col gap-4 ${type == "small" ? "p-4 lg:p-6" : ""}`}
113 >
114 {type == "small" && (
115 <h2 className="text-2xl font-semibold lg:text-3xl">
116 {poll.question} {/* Display poll question */}
117 </h2>
118 )}
119 <ul className="flex flex-col gap-2">
120 {poll.options.map((option, index) => {
121 const votesForOption = mergedVotes.filter(
122 (vote) => vote.option.documentId === option.documentId // Count votes for this option
123 ).length;
124 const totalVotes = mergedVotes.length; // Get total votes
125 return (
126 <li
127 key={index}
128 className={`${type == "small" ? "" : "text-3xl"}`} // Set text size based on card type
129 >
130 <OptionBar
131 option={option}
132 votesCount={votesForOption} // Pass votes count for the option
133 totalVotes={totalVotes} // Pass total votes
134 onOptionClick={handleOptionClick} // Pass click handler
135 loading={loading} // Pass loading state
136 votes={mergedVotes} // Pass merged votes
137 />
138 </li>
139 );
140 })}
141 </ul>
142 {/* Link to view full poll */}
143 {type === "small" && (
144 <Link className="btn mt-auto" href={`/poll/${poll.documentId}`}>
145 View Poll →
146 </Link>
147 )}
148 {/* Display poll results for large card type */}
149 {type === "large" && (
150 <div className="poll-results py-8">
151 <h3 className="text-3xl font-semibold">Results</h3>
152 <hr />
153 <ul className="mt-6 flex flex-col gap-4">
154 {poll.options.map((option, index) => {
155 const votesForOption = mergedVotes.filter(
156 (vote) => vote.option.documentId === option.documentId // Get votes for the option
157 );
158 const votesPercentage =
159 (votesForOption.length / mergedVotes.length) * 100 || 0; // Calculate percentage
160 return (
161 <li key={index}>
162 <article>
163 <h4 className={`text-2xl`}>
164 {option.value} - {votesPercentage.toFixed(2)}% //
165 Display option value and percentage
166 </h4>
167 <ul className="flex flex-col gap-2">
168 {votesForOption.map((vote, index) => (
169 <li className="text-lg" key={index}>
170 {/* Display username of voters */}
171 <span>{vote?.user?.username}</span>
172 </li>
173 ))}
174 </ul>
175 </article>
176 </li>
177 );
178 })}
179 </ul>
180 </div>
181 )}
182 </div>
183 </article>
184 );
185};
186export default PollCard; // Export PollCard component
Let's break it down:
The PollCard
component consists of a main card structure that displays a poll's question and its options. It utilizes the OptionBar
subcomponent to visualize the voting progress for each option.
The PollCard
component is the main container for displaying a poll. It receives three props: poll
, votes
, and type
. The type
prop controls the card's appearance, either "small"
or "large"
, which affects its layout and the details displayed. In a "small" card, users can see the poll question and vote, while the "large" version shows detailed results for each option, including usernames of voters.
Each poll option is represented using the OptionBar
subcomponent. This displays the option's value, a dynamic progress bar based on the percentage of votes (votesCount / totalVotes
), and a button for voting. When a user clicks an option, the onOptionClick
function calls createVote
, which triggers an API request to register the vote. The loading state (loading
) is updated during the voting process, showing a loader icon when voting is in progress.
The createVote
function handles the actual vote submission, sending the poll and option IDs along with a token (from local storage) to the server. This action is wrapped in a promise to show appropriate toast notifications for loading, success, or error states.
Lastly, in the "large" card view, the poll-results
section displays the percentage of votes each option received and lists the usernames of voters for each option.
Now, let's add it to our home page.
In ./components/Home/Page.tsx
:
1// ./components/Home/Page.tsx
2
3// Use client-side rendering
4"use client";
5import PollDrawer from "@/components/Poll/Drawer"; // Import the PollDrawer component for creating new polls
6import { PollsResponse } from "@/types"; // Import the PollsResponse type for typing the polls data
7import restRequest from "@/utils/restRequest"; // Import the utility function for making API requests
8import { Plus } from "lucide-react"; // Import the Plus icon from Lucide
9import { useEffect, useState } from "react"; // Import React hooks for managing state and side effects
10import PollCard from "@/components/Poll/Card";
11import { db } from "@/utils/instant";
12// Function to fetch polls from the API
13const getPolls = async ({ token }: { token: string }) => {
14 // Construct the API URL for fetching polls with related data
15 const pollsUrl = `${process.env.NEXT_PUBLIC_API}/api/polls?populate\[votes\][populate][0]=option&populate[options]=*`;
16 // Make a request to the API to fetch polls
17 const pollsResponse = await restRequest<PollsResponse>({
18 url: pollsUrl,
19 headers: {
20 Authorization: `Bearer ${token}`, // Set the Authorization header with the token
21 },
22 });
23 return pollsResponse; // Return the polls data
24};
25const HomePage = () => {
26 const { data } = db.useQuery({ votes: {} });
27 const [polls, setPolls] = useState<PollsResponse | null>(null); // State to hold the fetched polls data
28 const [loading, setLoading] = useState<boolean>(true); // State to track loading status
29 // Function to handle fetching polls with error handling
30 const handleGetPolls = async (token: string) => {
31 try {
32 const data = await getPolls({ token }); // Call the getPolls function
33 setPolls(data); // Set the fetched polls data to state
34 setLoading(false); // Set loading to false after fetching
35 } catch (error) {
36 console.log("🚨🚨🚨🚨 ~ handleGetPolls ~ error", error); // Log any errors that occur
37 } finally {
38 setLoading(false); // Ensure loading is false in the end
39 }
40 };
41 // useEffect to fetch polls when the component mounts
42 useEffect(() => {
43 const token = localStorage.getItem("token"); // Retrieve the token from local storage
44 if (!token) {
45 return setLoading(false); // If no token, set loading to false
46 }
47 handleGetPolls(token); // Fetch polls if token is available
48 }, []);
49 return (
50 <main>
51 <header className="site-section">
52 <div className="wrapper flex w-full items-center justify-between gap-6">
53 <h1 className="text-4xl lg:text-7xl">Polls</h1> {/* Page title */}
54 <PollDrawer>
55 <button className="btn max-lg:pl-2">
56 {" "}
57 {/* Button to open the poll creation drawer */}
58 <span className="max-lg:hidden">Create a new poll</span>
59 <Plus className="icon" /> {/* Plus icon */}
60 </button>
61 </PollDrawer>
62 </div>
63 </header>
64 <section className="site-section">
65 <div className="wrapper">
66 {loading ? ( // Conditional rendering based on loading state
67 <p>Loading...</p>
68 ) : polls ? ( // If polls data is available
69 <>
70 {/* Display the number of polls found */}
71 <p>{polls.data.length} polls found</p>
72 <ul className="grid grid-cols-1 gap-4 lg:grid-cols-2 py-6">
73 {polls?.data.map((poll) => (
74 <li key={poll.id} className="">
75 <PollCard
76 votes={
77 data?.votes.filter(
78 (vote) => vote.poll.documentId === poll.documentId
79 ) || []
80 }
81 poll={poll}
82 />
83 </li>
84 ))}
85 </ul>
86 </>
87 ) : (
88 <p>No polls found. Make sure you are logged in.</p> // Message if no polls are found
89 )}
90 </div>
91 </section>
92 </main>
93 );
94};
95export default HomePage;
Now, in the updated HomePage
component, the main changes involve the integration of the PollCard
component to display individual polls fetched from the API, alongside some additional logic for handling votes:
PollCard
Component Integration: The PollCard
is now rendered for each poll fetched from the API. This allows users to view poll details and vote directly from the homepage. The votes
prop is passed to PollCard
, filtered to include only the relevant votes for each poll using:
votes={data?.votes.filter((vote) => vote.poll.documentId === poll.documentId) || []}
This ensures that only votes tied to the current poll are displayed.
Votes from db.useQuery
: The data
fetched from db.useQuery({ votes: {} })
includes all votes from our Instant DB. This data is used to filter and pass the relevant votes to each PollCard
. This setup allows for seamless reactivity as users vote on polls, updating the state of displayed votes.
With that, we should have something like this:
Our application is beginning to take shape. One more page we have to build is the dynamic poll page that displays a single poll, and additional data like who voted.
Create a new file - ./components/Poll/Page.tsx
:
1"use client"; // Indicates that this file contains client-side logic for Next.js
2import PollCard from "@/components/Poll/Card"; // Importing the PollCard component to display the poll
3import { useUserStore } from "@/store/useUserStore"; // Hook to get the current user from the user store
4import { InstantDBVote, PollResponse } from "@/types"; // Types for vote and poll data
5import { db } from "@/utils/instant"; // InstantDB client for real-time data
6import restRequest from "@/utils/restRequest"; // Utility for making REST requests
7import { Loader, Trash2 } from "lucide-react"; // Icons for loading spinner and trash/delete button
8import { useRouter } from "next/navigation"; // Hook for navigating between routes
9import { useCallback, useEffect, useState } from "react"; // React hooks for state and lifecycle management
10import { toast } from "sonner"; // Toast notifications for success and error messages
11const PollPage: React.FC<{
12 id: string;
13}> = ({ id }) => {
14 const router = useRouter(); // Router instance for navigating the user after an action
15 const { data } = db.useQuery({ votes: {} }); // Query votes from InstantDB
16 const { user } = useUserStore(); // Get the current logged-in user from the store
17 // State variables for holding poll data, loading state, and real-time vote updates
18 const [poll, setPoll] = useState<PollResponse | null>(null);
19 const [loading, setLoading] = useState<boolean>(true);
20 const pollUrl = `${process.env.NEXT_PUBLIC_API}/api/polls/${id}`; // Poll API URL
21 const [realTimeVotes, setRealTimeVotes] = useState<InstantDBVote[]>([]); // Holds filtered votes for real-time updates
22 // Function to fetch poll data from the server using the poll ID and user's token
23 const handleGetPoll = useCallback(
24 async (id: string, token: string) => {
25 try {
26 const pollResponse = await restRequest<PollResponse>({
27 url: `${pollUrl}?populate\[votes\][populate][0]=option&populate[options]=*`, // Fetch poll options and votes
28 headers: {
29 Authorization: `Bearer ${token}`, // Include token for authenticated requests
30 },
31 });
32 setPoll(pollResponse); // Update state with the fetched poll
33 // Filter and update the real-time votes based on the poll ID
34 if (data?.votes?.length)
35 setRealTimeVotes(
36 data?.votes.filter(
37 (vote: InstantDBVote) => vote.poll.documentId === id
38 )
39 );
40 setLoading(false); // Set loading to false once data is fetched
41 } catch (error) {
42 console.log("🚨🚨🚨🚨 ~ handleGetPoll ~ error", error); // Log any errors
43 } finally {
44 setLoading(false); // Ensure loading state is false even if there is an error
45 }
46 },
47 [data?.votes, pollUrl]
48 );
49 // Function to handle the deletion of the poll
50 const handleDeletePoll = (id: string, token: string) => {
51 toast.promise(
52 restRequest<PollResponse>({
53 url: pollUrl, // Endpoint for poll deletion
54 method: "DELETE", // HTTP DELETE method to remove the poll
55 headers: {
56 Authorization: `Bearer ${token}`, // Include token in the headers
57 },
58 }),
59 {
60 loading: (() => {
61 setLoading(true); // Set loading state while deleting the poll
62 return "Deleting poll..."; // Show a loading message
63 })(),
64 success: () => {
65 router.push("/"); // Redirect to the home page after successful deletion
66 return "Poll deleted successfully!"; // Show success message
67 },
68 error: (error) => {
69 console.log("🚨🚨🚨🚨 ~ handleDeletePoll ~ error", error); // Log error
70 return "An error occurred while deleting the poll."; // Show error message
71 },
72 finally: () => {
73 setLoading(false); // Ensure loading state is false when done
74 },
75 }
76 );
77 };
78 // Fetch the poll data when the component is mounted or the poll ID changes
79 useEffect(() => {
80 handleGetPoll(id, localStorage.getItem("token") || ""); // Fetch poll data with the token from localStorage
81 }, [handleGetPoll, id]);
82 // Update real-time votes whenever the vote data changes in InstantDB
83 useEffect(() => {
84 if (data?.votes?.length)
85 setRealTimeVotes(
86 data?.votes.filter((vote: InstantDBVote) => vote.poll.documentId === id)
87 );
88 }, [data, id]);
89 return (
90 <main>
91 <header className="site-section">
92 <div className="wrapper">
93 <h1 className="text-4xl lg:text-7xl">
94 {/* Display the poll question, or a fallback message if not found */}
95 {poll?.data.question || "No poll found"}
96 </h1>
97 {/* Display delete button if the poll belongs to the current user */}
98 {poll?.data && poll?.data?.user?.documentId == user?.documentId && (
99 <div className="action-cont">
100 <button
101 onClick={() =>
102 handleDeletePoll(id, localStorage.getItem("token") || "")
103 }
104 className="btn"
105 >
106 {loading ? (
107 <Loader className="icon" /> // Show a loading spinner while deleting
108 ) : (
109 <Trash2 className="icon" /> // Show trash icon for deletion
110 )}
111 </button>
112 </div>
113 )}
114 </div>
115 </header>
116 <section className="site-section">
117 <div className="wrapper">
118 {loading ? (
119 <p>Loading...</p> // Display loading message while fetching poll data
120 ) : (
121 <div>
122 {/* Display the poll card if poll data is available, otherwise show fallback message */}
123 {poll?.data ? (
124 <PollCard poll={poll.data} votes={realTimeVotes} type="large" />
125 ) : (
126 <p>No poll found. Try again later. </p>
127 )}
128 </div>
129 )}
130 </div>
131 </section>
132 </main>
133 );
134};
135export default PollPage; // Export the component as default for use in other parts of the application
Here, this PollPage
component dynamically fetches and displays poll data, allowing users to interact with polls in real-time. Here's a breakdown of the key functionality:
handleGetPoll
function, which sends a request to an API endpoint (pollUrl
) to retrieve the poll information and associated votes. The poll data is set in state (poll
) and real-time votes are filtered from the database (InstantDB
) and stored in the realTimeVotes
state.useUserStore
) and accesses votes via the Instant DB client. The restRequest
utility is used to make authenticated requests to the poll API.poll?.data?.user?.documentId == user?.documentId
), they can delete it using the handleDeletePoll
function. This function sends a DELETE
request to the API, and on success, the user is redirected to the home page. The toast.promise
method is used to handle loading, success, and error notifications during the deletion process.So essentially, this component handles dynamic poll data retrieval, real-time updates, and user interactions.
To use this component, we'll have to create a page - ./app/poll/[id]/page.tsx
:
1// ./app/poll/[id]/page.tsx
2import PollPage from "@/components/Poll/Page";
3const Poll = ({
4 params,
5}: {
6 params: {
7 id: string;
8 };
9}) => <PollPage id={params.id} />;
10export default Poll;
And with that, we should have something like this when we view a poll:
Let's see it in action:
Now, let's test the real-time voting:
Awesome.
As a bonus, we're going to walk through one of the many ways we can deploy our project. For this tutorial, we'll be using Render to host the Strapi Backend and Netlify to host the Next.js frontend.
For our database, we can use Supabase to create a new Postgres backend that we can access directly from our Strapi project and use.
Head over to https://supabase.com, create an account if you don't have one already, and create a new project. Follow the steps and create a new database:
Once the project and the database has been created, navigate to Settings > Database > Connection Parameters and copy the values into the respective fields in your .env
file in your Strapi project.
Your .env
should look something like this now:
# Server
HOST=0.0.0.0
PORT=1337
# Secrets
APP_KEYS=siu/nUfUMCP5E/kqa5K5w==,qxC48s7tsOWvhtCloWUMsA==,fawSro5hJdVjylRG7orR3w==
API_TOKEN_SALT=fff/L5lA==
ADMIN_JWT_SECRET=U+ff/zhCA==
TRANSFER_TOKEN_SALT=33yBs9/d==
# Database
DATABASE_CLIENT=postgres
DATABASE_HOST=aws-0-eu-central-1.pooler.supabase.com
DATABASE_PORT=6543
DATABASE_NAME=postgres
DATABASE_USERNAME=postgres.dwuooudsjfifs6d7sdf
DATABASE_PASSWORD=pass1234
DATABASE_SSL=false
DATABASE_FILENAME=.tmp/data.db
JWT_SECRET=7yusdyfvyudf7sdfh==
# Instant DB
INSTANT_APP_ID=ee543ff6-c9cf-4a8b-8900-504994f3860a
INSTANT_ADMIN_TOKEN=b8ff93c0-8900-4334-ge45-2cd13aea2277
Next, install pg to
use the Postgres database.
npm install pg --save
Now that we've created our database and configured our .env
, we can upload our project to GitHub and deploy it from Strapi Cloud.
Connect your GitHub account:
In your Strapi Cloud dashboard, click on the + Create Project button and Connect your GitHub repository.
Update GitHub app permissions if you do not see your account to select and select the repository.
Click on Deploy and it should start building
In order to add our Instant DB ID and Token we can navigate to Settiings > Variables
In the variables form, click on + Add variable
Save the variables and trigger deployment.
Now, if you visit the generated URL, you should see your Strapi Admin dashboard:
Similarly, we can create a repository for our Next.js project on Netlify and push our code there. Then, in our Netlify dashboard, we can create a new project by clicking on Add new site > Import an existing project.
Select the project and in the configuration, enter the environment variables:
Click on Deploy and you should have your site live in minutes!
Here's a short GIF demonstrating the working application:
In this tutorial, we successfully built a real-time voting system using Strapi 5 and InstantDB. We walked through setting up a new Next.js project, integrating user authentication, initializing InstantDB for real-time data synchronization, and creating dynamic poll components. The backend was powered by Strapi 5, allowing us to manage poll-related content efficiently, while InstantDB enabled seamless real-time updates across clients.
Throughout the process of this part, we touched on several important concepts:
By combining these technologies, we demonstrated how to create a full-stack voting system that could be adapted to various real-time use cases, offering both flexibility and scalability.
Here are some resources that might help you continue developing real-time applications:
These resources provide further insights into building scalable full-stack applications and deploying them to the cloud. Be sure to explore them to deepen your knowledge and skills.