Simply copy and paste the following command line in your terminal to create your first Strapi project.
npx create-strapi-app
my-project
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
// ./types/index.ts
type User = {
id: number;
documentId: string;
username?: string;
email?: string;
provider?: string;
confirmed?: boolean;
blocked?: boolean;
createdAt?: string;
updatedAt?: string;
publishedAt?: string;
locale?: null;
};
type Option = {
id: number;
documentId: string;
value: string;
createdAt: string;
updatedAt: string;
publishedAt: string;
locale: null;
};
type Vote = {
id: number;
documentId: string;
createdAt: string;
updatedAt: string;
publishedAt: string;
locale: string | null;
option: Option;
user?: User;
};
type Poll = {
id: number;
documentId: string;
question: string;
createdAt: string;
updatedAt: string;
publishedAt: string;
locale: null;
options: Option[];
votes: Vote[];
user?: User;
};
type VoteData = {
id: number;
documentId: string;
createdAt: string;
updatedAt: string;
publishedAt: string;
locale: string | null;
option: Option;
poll: Poll;
};
type Meta = {
pagination: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
type PollsResponse = {
data: Poll[];
meta: Meta;
};
type PollResponse = {
data: Poll;
};
type OptionResponse = {
data: Option;
};
type VotesResponse = {
data: VoteData[];
meta: Meta;
};
type VoteResponse = {
data: VoteData;
};
type InstantDBVote = {
user: {
documentId: string;
username: string;
email: string;
};
poll: {
question: string;
documentId: string;
};
option: {
value: string;
documentId: string;
};
createdAt: string;
};
type InstantDBSchema = {
votes: InstantDBVote;
};
export type {
User,
Option,
Vote,
Poll,
Meta,
PollsResponse,
PollResponse,
OptionResponse,
VotesResponse,
VoteResponse,
VoteData,
InstantDBVote,
InstantDBSchema,
};
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// ./utils/restRequest.ts
// Define a generic type for options passed into the request
type RestRequestOptions<B> = {
url: string; // URL endpoint to which the request is sent
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; // HTTP method (default is "GET")
headers?: Record<string, string>; // Optional headers for the request
body?: B; // Optional body for the request, can be any type
};
// A generic fetch wrapper function that handles REST API requests
async function restRequest<T, B = undefined>({
url,
method = "GET", // Default method is GET
headers = {}, // Default headers are an empty object
body, // Optional body for requests like POST or PUT
}: RestRequestOptions<B>): Promise<T> {
try {
// Send a fetch request with the provided options
const response = await fetch(url, {
method, // Use the specified HTTP method
headers: {
"Content-Type": "application/json", // Set content type to JSON by default
...headers, // Merge additional headers passed in
},
body: body ? JSON.stringify(body) : undefined, // Stringify the body if provided
});
// If the response is not successful, parse and throw an error
if (!response.ok) {
const errorData = await response.json(); // Parse the error response
console.log("🚨🚨🚨🚨 ~ error data:", errorData); // Log error details
throw new Error(
errorData?.message || errorData?.error?.message || response.statusText
); // Throw the error with a message
}
// If the response status is 204 (No Content), return an empty object
if (response.status === 204) {
return {} as T;
}
// Parse the successful response as JSON and return it
const data = (await response.json()) as T;
return data;
} catch (error) {
console.error("Error in restRequest:", error); // Log any errors that occur during the request
throw error; // Rethrow the error to be handled by the caller
}
}
export 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ./store/useUserStore.ts
import { User } from "@/types";
import { create } from "zustand";
// define the UserStore type
type UserStore = {
user: User | null;
setUser: (user: User | null) => void;
};
// create the UserStore
export const useUserStore = create<UserStore>((set) => ({
// initialize the user to null
user: null,
// define the setUser function to update the user
setUser: (user) => set({ user }),
}));
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
// ./components/Auth/Form.tsx
// Import necessary modules and hooks
"use client"; // Enables client-side rendering in a Next.js app
import { useUserStore } from "@/store/useUserStore"; // Zustand store for managing user state
import { Loader } from "lucide-react"; // Loader icon from the lucide-react library
import Link from "next/link"; // Link component for navigation
import { useRouter } from "next/navigation"; // Next.js hook for programmatic navigation
import { useState } from "react"; // React hook for managing local component state
import { toast } from "sonner"; // Toast notifications for showing feedback
import { db } from "@/utils/instant"; // InstantDB client for interacting with the database
import restRequest from "@/utils/restRequest"; // Utility function for making REST API requests
import {
LoginBody,
LoginResponse,
RegisterBody,
RegsiterResponse,
} from "@/types"; // Types for login and registration
// Function to handle user login using the Strapi API
const loginUser = async ({ identifier, password }: LoginBody) => {
try {
// Send login request to Strapi API
const data = await restRequest<LoginResponse, LoginBody>({
url: `${process.env.NEXT_PUBLIC_API}/api/auth/local`,
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: {
identifier,
password,
},
});
return data;
} catch (error) {
// Log and throw any errors that occur
console.log("🚨🚨🚨🚨 ~ loginUser error:", error);
throw error;
}
};
// Function to handle user registration using the Strapi API
const registerUser = async ({ username, email, password }: RegisterBody) => {
try {
// Send registration request to Strapi API
const data = await restRequest<RegsiterResponse, RegisterBody>({
url: `${process.env.NEXT_PUBLIC_API}/api/auth/local/register`,
method: "POST",
body: {
username,
email,
password,
},
headers: {
"Content-Type": "application/json",
},
});
return data;
} catch (error) {
// Log and throw any errors that occur
console.log("🚨🚨🚨🚨 ~ register error:", error);
throw error;
}
};
// AuthForm component to handle both login and registration
const AuthForm: React.FC<{
type: "login" | "register"; // The form type: "login" or "register"
}> = ({ type }) => {
const router = useRouter(); // Use Next.js router for navigation
const { setUser } = useUserStore(); // Zustand store function to set the user
// State variables for form fields and loading state
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
// Handle form submission for both login and registration
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); // Prevent the default form submission
if (type === "login") {
// Handle login case
return toast.promise(
loginUser({ identifier: email.trim(), password: password.trim() }),
{
loading: (() => {
setLoading(true); // Set loading state to true
return "Logging in..."; // Display loading message
})(),
success: (data) => {
// On success, store JWT, user info, and InstantDB token in localStorage
localStorage.setItem("token", data.jwt);
localStorage.setItem("user", JSON.stringify(data.user));
localStorage.setItem("instantDBToken", data.instantdbToken);
setUser(data.user); // Set user in Zustand store
db?.auth.signInWithToken(data.instantdbToken); // Authenticate with InstantDB
router.push("/"); // Redirect to the homepage
return "Logged in successfully";
},
error: (err) => {
// Handle errors and display error message
console.log("🚨🚨🚨🚨 ~ handleSubmit ~ err", err);
return err.message; // Display error in toast notification
},
finally: () => {
setLoading(false); // Reset loading state
},
}
);
}
if (type === "register") {
// Handle registration case
return toast.promise(
registerUser({
username: username.trim(),
email: email.trim(),
password: password.trim(),
}),
{
loading: (() => {
setLoading(true); // Set loading state to true
return "Registering..."; // Display loading message
})(),
success: (data) => {
// On success, store JWT, user info, and InstantDB token in localStorage
localStorage.setItem("token", data.jwt);
localStorage.setItem("user", JSON.stringify(data.user));
localStorage.setItem("instantDBToken", data.instantdbToken);
setUser(data.user); // Set user in Zustand store
db?.auth.signInWithToken(data.instantdbToken); // Authenticate with InstantDB
router.push("/"); // Redirect to the homepage
return "Registered successfully";
},
error: (err) => {
// Handle errors and display error message
console.log("🚨🚨🚨🚨 ~ handleSubmit ~ err", err);
return err.message; // Display error in toast notification
},
finally: () => {
setLoading(false); // Reset loading state
},
}
);
}
};
return (
<>
{/* Form for login or registration */}
<form
onSubmit={handleSubmit}
className="border border-zinc-200 bg-zinc-50 p-4 lg:p-6"
>
<div className="wrapper flex flex-col gap-8">
{/* Show username field only for registration */}
{type === "register" && (
<div className="form-control">
<label htmlFor="username">Username</label>
<input
type="text"
name="username"
id="username"
onChange={(e) => setUsername(e.target.value)}
value={username}
className="form-input"
required
/>
</div>
)}
{/* Email input field */}
<div className="form-control">
<label htmlFor="email">Email</label>
<input
type="email"
name="email"
id="email"
onChange={(e) => setEmail(e.target.value)}
value={email}
className="form-input"
required
/>
</div>
{/* Password input field */}
<div className="form-control">
<label htmlFor="password">Password</label>
<input
type="password"
name="password"
id="password"
onChange={(e) => setPassword(e.target.value)}
value={password}
className="form-input"
required
/>
</div>
{/* Submit button */}
<div className="action-cont">
<button
disabled={!email.trim() || !password.trim() || loading}
className="btn"
type="submit"
>
{type === "login" ? "Login" : "Register"}
{loading && <Loader className="icon animate-spin" />}
</button>
</div>
</div>
</form>
{/* Display link to switch between login and registration */}
{type == "login" ? (
<p className="mt-6">
Don't have an account?{" "}
<Link className="underline" href="/register">
Create one
</Link>
</p>
) : (
<p className="mt-6">
Already have an account?{" "}
<Link className="underline" href="/login">
Login
</Link>
</p>
)}
</>
);
};
export 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// ./components/Site/Header.tsx
"use client"; // This enables client-side rendering for this component.
import { useUserStore } from "@/store/useUserStore"; // Import the Zustand store to access user state.
import Link from "next/link"; // Import Link from Next.js for navigation.
import { useEffect } from "react"; // Import useEffect to run code on component mount.
const SiteHeader = () => {
// Extract user state and setUser function from Zustand store
const { user, setUser } = useUserStore();
// Handles user logout by clearing localStorage and resetting the user state.
const handleLogout = async () => {
localStorage.removeItem("user"); // Remove user data from local storage.
localStorage.removeItem("token"); // Remove authentication token from local storage.
localStorage.removeItem("instantDBToken"); // Remove InstantDB token from local storage.
setUser(null); // Update the Zustand store to reflect no user is logged in.
window.location.reload(); // Refresh the page to update the UI.
};
useEffect(() => {
// On component mount, retrieve user from localStorage if present and set it in the store.
const user = localStorage.getItem("user");
if (user) {
setUser(JSON.parse(user)); // Parse the JSON string and set the user state in the store.
}
}, [setUser]); // Effect runs when component mounts, no dependencies.
return (
<header className="sticky top-0 w-full bg-white border-b border-b-zinc-200 p-4">
{/* Container for the site header */}
<div className="wrapper mx-auto flex w-full max-w-3xl items-center justify-between">
{/* Logo or site name that links back to the homepage */}
<Link href="/">
<figure>
<h1 className="text-lg font-bold">Votes.</h1>
</figure>
</Link>
{/* Navigation section */}
<nav className="site-nav">
<ul className="flex items-center gap-4">
{/* Conditional rendering based on user login status */}
{user ? (
// If user is logged in, display a welcome message and logout button
<>
<li>
<p className="truncate">
Welcome, <strong>{user.username}</strong>
</p>
</li>
<li>
<button onClick={handleLogout}>Logout</button>
</li>
</>
) : (
// If user is not logged in, show login and register links
<>
<li>
<Link href="/login">Login</Link>
</li>
<li>
<Link href="/register">Register</Link>
</li>
</>
)}
</ul>
</nav>
</div>
</header>
);
};
export 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// ./app/layout.tsx
// Import global CSS styles and necessary components
import "./globals.css"; // Imports global CSS file for styling
import { Toaster } from "@/components/ui/sonner"; // Imports a custom Toaster component for notifications
import SiteHeader from "@/components/Site/Header"; // Imports the header component for the site
import { Metadata } from "next"; // Type definition for page metadata in Next.js
// Define metadata for the page, including title and description for SEO purposes
export const metadata: Metadata = {
title: "Votes.", // Title of the webpage
description: "Express your opinion with Votes and polls.", // Brief description of the site
};
// Root layout component that wraps the entire application
export default function RootLayout({
children, // Accepts children components to be rendered within the layout
}: Readonly<{
children: React.ReactNode; // Defines the type for children, which are React components
}>) {
return (
<html lang="en">
{" "}
{/* Sets the language attribute for the HTML document */}
<body>
<SiteHeader /> {/* Renders the SiteHeader component at the top */}
<Toaster richColors theme="system" className="bg-white" />{" "}
{/* Renders the Toaster for displaying notifications, using system theme */}
{children} {/* Renders any child components passed into the layout */}
</body>
</html>
);
}
With that, we should have something like this:
Next, we'll create the authentication pages.
Create a new file - ./app/register/page.tsx
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// ./app/register/page.tsx
// Import the AuthForm component which will handle the registration form
import AuthForm from "@/components/Auth/Form";
// Define a functional component for the Register page
const RegisterPage = () => {
return (
<main> {/* The main content of the page */}
<header className="site-section"> {/* A header section for the page title */}
<div className="wrapper"> {/* Wrapper to contain and center the content */}
<h1 className="text-3xl lg:text-7xl">Register</h1> {/* Large heading for the page title, with responsive sizing */}
</div>
</header>
<section className="site-section"> {/* A section to contain the registration form */}
<div className="wrapper mx-auto max-w-3xl"> {/* Wrapper that centers the content and limits the width to 3xl */}
<AuthForm type="register" /> {/* Renders the AuthForm component with "register" as the type for the form */}
</div>
</section>
</main>
);
};
// Export the RegisterPage component to be used in the application
export 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// ./app/login/page.tsx
// Import the AuthForm component which will handle the registration form
import AuthForm from "@/components/Auth/Form";
// Define a functional component for the Login page
const LoginPage = () => {
return (
<main>
{/* The main content of the page */}
<header className="site-section">
{/* A header section for the page title */}
<div className="wrapper">
{/* Wrapper to contain and center the content */}
<h1 className="text-3xl lg:text-7xl">Login</h1>
{/* Large heading for the page title, with responsive sizing */}
</div>
</header>
<section className="site-section">
{/* A section to contain the registration form */}
<div className="wrapper mx-auto max-w-3xl">
{/* Wrapper that centers the content and limits the width to 3xl */}
<AuthForm type="login" />
{/* Renders the AuthForm component with "login" as the type for the form */}
</div>
</section>
</main>
);
};
// Export the LoginPage component to be used in the application
export 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
:
1
2
3
4
5
6
7
import { init } from "@instantdb/react";
import { InstantDBSchema } from "@/types";
// ID for app: Voting App
const APP_ID = process.env.NEXT_PUBLIC_INSTANT_APP_ID as string;
export 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
// ./components/Poll/Form.tsx
// Import necessary React hooks and libraries
import { useState } from "react"; // React hook to manage component state
import { toast } from "sonner"; // Importing toast notification from sonner
import { OptionResponse, PollResponse } from "@/types"; // Type definitions for API responses
import restRequest from "@/utils/restRequest"; // Utility function for making API requests
import { useRouter } from "next/navigation"; // Router hook for navigation
import { Loader, X } from "lucide-react"; // Icons for loading and remove option
// Async function to create a poll, accepts question, options, and token as parameters
const createPoll = async ({
question,
options,
token,
}: {
question: string;
options: string[];
token: string;
}) => {
try {
// API URLs for creating polls and options
const pollUrl = `${process.env.NEXT_PUBLIC_API}/api/polls?populate=*`;
const optionUrl = `${process.env.NEXT_PUBLIC_API}/api/options?populate=*`;
// Create options by making API requests for each one
const optionsResponses = await Promise.all(
options.map(async (option) => {
return await restRequest<OptionResponse, { data: { value: string } }>({
url: optionUrl,
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: {
data: {
value: option, // The option text
},
},
});
})
);
// Create the poll by sending the question and option IDs
const pollResponse = await restRequest<
PollResponse,
{ data: { question: string; options: string[] } }
>({
url: pollUrl,
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: {
data: {
question, // Poll question
options: optionsResponses.map((response) => response.data.documentId), // Map the option IDs
},
},
});
return pollResponse; // Return the created poll response
} catch (error) {
console.log("🚨🚨🚨🚨 ~ createPoll ~ error", error); // Log any errors
throw error; // Rethrow the error to handle it in the calling function
}
};
// Component for rendering individual options with a remove button
const OptionItem: React.FC<{
option: string;
updateRemoveOption: (option: string) => void; // Callback to remove the option
}> = ({ option, updateRemoveOption }) => {
return (
<li className="inline-flex w-fit items-center justify-between border border-zinc-200 bg-zinc-100">
<span className="px-4">{option}</span> {/* Display option text */}
<button
className="btn h-full grow"
onClick={() => {
updateRemoveOption(option); // Trigger option removal when button is clicked
}}
>
<X className="icon" />{" "}
{/* Display the 'X' icon for removing the option */}
</button>
</li>
);
};
// Main PollForm component
const PollForm: React.FC = ({}) => {
const router = useRouter(); // Hook to navigate between pages
const [question, setQuestion] = useState<string>(""); // State to manage the poll question
const [options, setOptions] = useState<string[]>([]); // State to manage poll options
const [option, setOption] = useState<string>(""); // State to manage the current option input
const [loading, setLoading] = useState<boolean>(false); // State to manage loading state
// Function to add the current option to the options list
const addOption = () => {
setOptions([...options, option]); // Add the current option to the options array
setOption(""); // Clear the input field
};
// Function to handle the creation of a poll
const handleCreatePoll = () => {
// Display a toast notification during the poll creation process
toast.promise(
createPoll({
question,
options,
token: localStorage.getItem("token") || "", // Retrieve the token from local storage
}),
{
loading: (() => {
setLoading(true); // Set loading state to true while the request is being processed
return "Creating poll..."; // Loading message
})(),
success: (data) => {
// Reset the form upon success
setQuestion(""); // Clear the question
setOptions([]); // Clear the options
setOption(""); // Clear the option input
router.push(`/poll/${data.data.documentId}`); // Redirect to the created poll page
return "Poll created!"; // Success message
},
error: (error) => {
return error.message; // Display error message in the toast
},
finally: () => {
setLoading(false); // Reset loading state after completion
},
}
);
};
return (
// Form for creating a poll
<form
onSubmit={(e) => {
e.preventDefault(); // Prevent default form submission
handleCreatePoll(); // Trigger poll creation
}}
className="border border-zinc-200 bg-zinc-50 p-4 lg:p-6"
>
<div className="wrapper flex flex-col gap-4">
<div className="form-control">
<label htmlFor="question">Question</label>{" "}
{/* Label for question input */}
<input
type="text"
id="question"
name="question"
value={question} // Bind input value to the question state
onChange={(e) => setQuestion(e.target.value)} // Update state when input changes
/>
</div>
<div className="form-control">
<label htmlFor="option">Option</label> {/* Label for option input */}
<div className="flex">
<input
type="text"
id="option"
name="option"
value={option} // Bind input value to the option state
onChange={(e) => setOption(e.target.value)} // Update state when input changes
/>
<button className="btn" type="button" onClick={addOption}>
Add
</button>{" "}
{/* Button to add the option to the list */}
</div>
<ul className="flex gap-2">
{/* Map through the options array to display each option */}
{options.map((option) => (
<OptionItem
key={option}
option={option} // Pass option text
updateRemoveOption={(option) => {
setOptions(options.filter((opt) => opt !== option)); // Remove option from the list
}}
/>
))}
</ul>
</div>
<div className="action-cont">
{/* Button to submit the form and create the poll */}
<button className="btn" type="submit" disabled={loading}>
Create Poll
{loading && <Loader className="icon animate-spin" />}{" "}
{/* Show loader while processing */}
</button>
</div>
</div>
</form>
);
};
export 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// ./components/Poll/Drawer.tsx
// Import necessary components from the drawer UI library
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import PollForm from "./Form";
// The PollDrawer component is a wrapper that displays a drawer containing the poll creation form.
const PollDrawer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
// Main Drawer component that toggles the drawer UI
<Drawer>
{/* The trigger that opens the drawer, renders the passed child element */}
<DrawerTrigger asChild>
{children || <button>Open Drawer</button>}
</DrawerTrigger>
{/* The content inside the drawer */}
<DrawerContent className="bg-white">
{/* A wrapper to control the drawer's width and center it */}
<div className="wrapper mx-auto w-full max-w-3xl">
{/* Drawer header contains the title and description */}
<DrawerHeader>
<DrawerTitle>Create a new poll</DrawerTitle>
<DrawerDescription>
Fill in the form below to create a new poll.
</DrawerDescription>
</DrawerHeader>
{/* Drawer body contains the poll creation form */}
<div className="p-4">
<PollForm />
</div>
{/* Drawer footer with a cancel button that closes the drawer */}
<DrawerFooter>
<DrawerClose asChild>
<button className="btn w-full grow">Cancel</button>
</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
);
};
export 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// ./components/Home/Page.tsx
// Use client-side rendering
"use client";
import PollDrawer from "@/components/Poll/Drawer"; // Import the PollDrawer component for creating new polls
import { PollsResponse } from "@/types"; // Import the PollsResponse type for typing the polls data
import restRequest from "@/utils/restRequest"; // Import the utility function for making API requests
import { Plus } from "lucide-react"; // Import the Plus icon from Lucide
import { useEffect, useState } from "react"; // Import React hooks for managing state and side effects
// Function to fetch polls from the API
const getPolls = async ({ token }: { token: string }) => {
// Construct the API URL for fetching polls with related data
const pollsUrl = `${process.env.NEXT_PUBLIC_API}/api/polls?populate\[votes\][populate][0]=option&populate[options]=*`;
// Make a request to the API to fetch polls
const pollsResponse = await restRequest<PollsResponse>({
url: pollsUrl,
headers: {
Authorization: `Bearer ${token}`, // Set the Authorization header with the token
},
});
return pollsResponse; // Return the polls data
};
const HomePage = () => {
const [polls, setPolls] = useState<PollsResponse | null>(null); // State to hold the fetched polls data
const [loading, setLoading] = useState<boolean>(true); // State to track loading status
// Function to handle fetching polls with error handling
const handleGetPolls = async (token: string) => {
try {
const data = await getPolls({ token }); // Call the getPolls function
setPolls(data); // Set the fetched polls data to state
setLoading(false); // Set loading to false after fetching
} catch (error) {
console.log("🚨🚨🚨🚨 ~ handleGetPolls ~ error", error); // Log any errors that occur
} finally {
setLoading(false); // Ensure loading is false in the end
}
};
// useEffect to fetch polls when the component mounts
useEffect(() => {
const token = localStorage.getItem("token"); // Retrieve the token from local storage
if (!token) {
return setLoading(false); // If no token, set loading to false
}
handleGetPolls(token); // Fetch polls if token is available
}, []);
return (
<main>
<header className="site-section">
<div className="wrapper flex w-full items-center justify-between gap-6">
<h1 className="text-4xl lg:text-7xl">Polls</h1> {/* Page title */}
<PollDrawer>
<button className="btn max-lg:pl-2">
{" "}
{/* Button to open the poll creation drawer */}
<span className="max-lg:hidden">Create a new poll</span>
<Plus className="icon" /> {/* Plus icon */}
</button>
</PollDrawer>
</div>
</header>
<section className="site-section">
<div className="wrapper">
{loading ? ( // Conditional rendering based on loading state
<p>Loading...</p>
) : polls ? ( // If polls data is available
<p>{polls.data.length} polls found</p> // Display the number of polls found
) : (
<p>No polls found. Make sure you are logged in.</p> // Message if no polls are found
)}
</div>
</section>
</main>
);
};
export 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
2
3
4
5
// ./app/page.tsx
import HomePage from "@/components/Home/Page";
export default function Home() {
return <HomePage />;
}
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
// ./utils/poll/index.ts
import { InstantDBVote, Vote, VoteResponse } from "@/types"; // Import types for votes
import restRequest from "@/utils/restRequest"; // Import the utility for making REST requests
// Function to create a vote for a specific poll option
const createVote = async ({
poll, // Poll ID to which the vote is associated
option, // Option ID that is being voted for
token, // Authorization token for the API request
}: {
poll: string;
option: string;
token: string;
}) => {
// Define the API endpoint for creating votes
const voteUrl = `${process.env.NEXT_PUBLIC_API}/api/votes?populate=*`;
// Make the REST request to create a vote
const voteResponse = await restRequest<
VoteResponse,
{
data: {
option: string;
poll: string;
};
}
>({
url: voteUrl, // URL of the API endpoint
method: "POST", // HTTP method for creating a new resource
headers: {
Authorization: `Bearer ${token}`, // Include authorization token in headers
},
body: {
data: {
poll, // Include the poll ID in the request body
option, // Include the option ID in the request body
},
},
});
return voteResponse; // Return the response from the vote creation
};
// Combine votes from the poll and real-time votes from InstantDB
const mergeVotes = (
pollVotes: Vote[], // Existing votes from the poll
realTimeVotes: InstantDBVote[] // Real-time votes to be merged
): Vote[] => {
const mergedVotes = [...pollVotes]; // Start with existing poll votes
// Convert realTimeVotes into Vote objects and add them if they don't exist
realTimeVotes.forEach((instantVote) => {
const existsInPoll = pollVotes.some(
(pollVote) =>
pollVote.option.documentId === instantVote.option.documentId &&
pollVote.user?.documentId === instantVote.user.documentId
);
// If the real-time vote does not already exist in pollVotes, transform and add it
if (!existsInPoll) {
mergedVotes.push({
id: Math.random(), // Placeholder ID since real-time votes won't have it
documentId: instantVote.poll.documentId,
createdAt: instantVote.createdAt,
updatedAt: instantVote.createdAt,
publishedAt: instantVote.createdAt,
locale: null,
option: {
documentId: instantVote.option.documentId,
value: instantVote.option.value,
id: Math.random(), // Placeholder ID for option
createdAt: instantVote.createdAt,
updatedAt: instantVote.createdAt,
publishedAt: instantVote.createdAt,
locale: null,
},
user: {
documentId: instantVote.user.documentId,
username: instantVote.user.username,
email: instantVote.user.email,
id: Math.random(), // Placeholder ID for user
},
});
}
});
return mergedVotes; // Return the combined list of votes
};
export { 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
// ./components/Poll/Card.tsx
// Importing necessary libraries and types
import { useUserStore } from "@/store/useUserStore";
import { InstantDBVote, Option, Poll, Vote } from "@/types";
import { createVote, mergeVotes } from "@/utils/poll";
import { Check, Loader, VoteIcon } from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { toast } from "sonner";
// Component for displaying an option in the poll with a progress bar for votes
const OptionBar: React.FC<{
option: Option; // The option object containing option details
votes?: Vote[]; // Array of votes for this option
votesCount: number; // Count of votes for this option
totalVotes: number; // Total votes for all options
onOptionClick?: (option: string) => void; // Function to call when the option is clicked
loading?: { [key: string]: boolean }; // Loading states for options
}> = ({ option, votesCount, votes, totalVotes, onOptionClick, loading }) => {
const { user } = useUserStore(); // Get user details from the store
// Check if the user has voted for this option
const userVotedForOption = votes?.find(
(vote) =>
vote.user?.documentId === user?.documentId &&
vote.option.documentId === option.documentId
);
return (
<div className="flex gap-2">
<div className="relative flex w-full items-center justify-between">
<div className="relative flex w-full items-center justify-between border border-zinc-200">
{/* Progress bar representing the percentage of votes */}
<div
className="absolute h-full bg-zinc-300 transition-all"
style={{ width: `${(votesCount / totalVotes) * 100}%` }} // Calculate width based on votes
></div>
<span className="relative p-2 text-zinc-900">{option.value}</span>
<span className="right-0 z-10 p-2 px-4">{votesCount}</span>
</div>
</div>
{/* Button for voting */}
<button
onClick={() => {
if (onOptionClick) {
onOptionClick(option.documentId); // Call the option click handler with option ID
}
}}
className="btn shrink-0"
disabled={loading?.[option.documentId] || !!userVotedForOption} // Disable if loading or user has voted
>
{/* Display different icons based on voting state */}
{!loading?.[option.documentId] ? (
!userVotedForOption ? (
<VoteIcon className="icon shrink-0" /> // Voting icon
) : (
<Check className="icon shrink-0" /> // Checkmark icon if voted
)
) : (
<Loader className="icon shrink-0 animate-spin" /> // Loader icon if loading
)}
</button>
</div>
);
};
// Main component for displaying the poll card
const PollCard: React.FC<{
poll: Poll; // The poll object containing poll details
votes: InstantDBVote[]; // Array of real-time votes
type?: "small" | "large"; // Type of poll card for styling
}> = ({ poll, votes, type = "small" }) => {
const [loading, setLoading] = useState<{ [key: string]: boolean }>(); // Loading state for options
// Memoize merged votes to avoid recalculating on each render
const mergedVotes = useMemo(
() => mergeVotes(poll.votes, votes), // Merge existing poll votes with real-time votes
[poll.votes, votes]
);
// Handle voting option click
const handleOptionClick = (option: string) => {
toast.promise(
createVote({
poll: poll.documentId, // Poll ID
option, // Selected option ID
token: localStorage.getItem("token") || "", // Get token from local storage
}),
{
loading: (() => {
setLoading({
...loading,
[option]: true, // Set loading state for the clicked option
});
return "Voting..."; // Loading message
})(),
success: () => {
return "Voted successfully!"; // Success message
},
error: (error) => {
console.log("🚨🚨🚨🚨 ~ handleOptionClick ~ error", error); // Log error
return error.message; // Return error message
},
finally: () => {
setLoading({
...loading,
[option]: false, // Reset loading state for the clicked option
});
},
}
);
};
return (
<article
className={`poll-card h-full ${type == "small" ? "border border-zinc-200 bg-zinc-50" : ""}`}
>
<div
className={`wrapper flex h-full flex-col gap-4 ${type == "small" ? "p-4 lg:p-6" : ""}`}
>
{type == "small" && (
<h2 className="text-2xl font-semibold lg:text-3xl">
{poll.question} {/* Display poll question */}
</h2>
)}
<ul className="flex flex-col gap-2">
{poll.options.map((option, index) => {
const votesForOption = mergedVotes.filter(
(vote) => vote.option.documentId === option.documentId // Count votes for this option
).length;
const totalVotes = mergedVotes.length; // Get total votes
return (
<li
key={index}
className={`${type == "small" ? "" : "text-3xl"}`} // Set text size based on card type
>
<OptionBar
option={option}
votesCount={votesForOption} // Pass votes count for the option
totalVotes={totalVotes} // Pass total votes
onOptionClick={handleOptionClick} // Pass click handler
loading={loading} // Pass loading state
votes={mergedVotes} // Pass merged votes
/>
</li>
);
})}
</ul>
{/* Link to view full poll */}
{type === "small" && (
<Link className="btn mt-auto" href={`/poll/${poll.documentId}`}>
View Poll →
</Link>
)}
{/* Display poll results for large card type */}
{type === "large" && (
<div className="poll-results py-8">
<h3 className="text-3xl font-semibold">Results</h3>
<hr />
<ul className="mt-6 flex flex-col gap-4">
{poll.options.map((option, index) => {
const votesForOption = mergedVotes.filter(
(vote) => vote.option.documentId === option.documentId // Get votes for the option
);
const votesPercentage =
(votesForOption.length / mergedVotes.length) * 100 || 0; // Calculate percentage
return (
<li key={index}>
<article>
<h4 className={`text-2xl`}>
{option.value} - {votesPercentage.toFixed(2)}% //
Display option value and percentage
</h4>
<ul className="flex flex-col gap-2">
{votesForOption.map((vote, index) => (
<li className="text-lg" key={index}>
{/* Display username of voters */}
<span>{vote?.user?.username}</span>
</li>
))}
</ul>
</article>
</li>
);
})}
</ul>
</div>
)}
</div>
</article>
);
};
export 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
// ./components/Home/Page.tsx
// Use client-side rendering
"use client";
import PollDrawer from "@/components/Poll/Drawer"; // Import the PollDrawer component for creating new polls
import { PollsResponse } from "@/types"; // Import the PollsResponse type for typing the polls data
import restRequest from "@/utils/restRequest"; // Import the utility function for making API requests
import { Plus } from "lucide-react"; // Import the Plus icon from Lucide
import { useEffect, useState } from "react"; // Import React hooks for managing state and side effects
import PollCard from "@/components/Poll/Card";
import { db } from "@/utils/instant";
// Function to fetch polls from the API
const getPolls = async ({ token }: { token: string }) => {
// Construct the API URL for fetching polls with related data
const pollsUrl = `${process.env.NEXT_PUBLIC_API}/api/polls?populate\[votes\][populate][0]=option&populate[options]=*`;
// Make a request to the API to fetch polls
const pollsResponse = await restRequest<PollsResponse>({
url: pollsUrl,
headers: {
Authorization: `Bearer ${token}`, // Set the Authorization header with the token
},
});
return pollsResponse; // Return the polls data
};
const HomePage = () => {
const { data } = db.useQuery({ votes: {} });
const [polls, setPolls] = useState<PollsResponse | null>(null); // State to hold the fetched polls data
const [loading, setLoading] = useState<boolean>(true); // State to track loading status
// Function to handle fetching polls with error handling
const handleGetPolls = async (token: string) => {
try {
const data = await getPolls({ token }); // Call the getPolls function
setPolls(data); // Set the fetched polls data to state
setLoading(false); // Set loading to false after fetching
} catch (error) {
console.log("🚨🚨🚨🚨 ~ handleGetPolls ~ error", error); // Log any errors that occur
} finally {
setLoading(false); // Ensure loading is false in the end
}
};
// useEffect to fetch polls when the component mounts
useEffect(() => {
const token = localStorage.getItem("token"); // Retrieve the token from local storage
if (!token) {
return setLoading(false); // If no token, set loading to false
}
handleGetPolls(token); // Fetch polls if token is available
}, []);
return (
<main>
<header className="site-section">
<div className="wrapper flex w-full items-center justify-between gap-6">
<h1 className="text-4xl lg:text-7xl">Polls</h1> {/* Page title */}
<PollDrawer>
<button className="btn max-lg:pl-2">
{" "}
{/* Button to open the poll creation drawer */}
<span className="max-lg:hidden">Create a new poll</span>
<Plus className="icon" /> {/* Plus icon */}
</button>
</PollDrawer>
</div>
</header>
<section className="site-section">
<div className="wrapper">
{loading ? ( // Conditional rendering based on loading state
<p>Loading...</p>
) : polls ? ( // If polls data is available
<>
{/* Display the number of polls found */}
<p>{polls.data.length} polls found</p>
<ul className="grid grid-cols-1 gap-4 lg:grid-cols-2 py-6">
{polls?.data.map((poll) => (
<li key={poll.id} className="">
<PollCard
votes={
data?.votes.filter(
(vote) => vote.poll.documentId === poll.documentId
) || []
}
poll={poll}
/>
</li>
))}
</ul>
</>
) : (
<p>No polls found. Make sure you are logged in.</p> // Message if no polls are found
)}
</div>
</section>
</main>
);
};
export 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
"use client"; // Indicates that this file contains client-side logic for Next.js
import PollCard from "@/components/Poll/Card"; // Importing the PollCard component to display the poll
import { useUserStore } from "@/store/useUserStore"; // Hook to get the current user from the user store
import { InstantDBVote, PollResponse } from "@/types"; // Types for vote and poll data
import { db } from "@/utils/instant"; // InstantDB client for real-time data
import restRequest from "@/utils/restRequest"; // Utility for making REST requests
import { Loader, Trash2 } from "lucide-react"; // Icons for loading spinner and trash/delete button
import { useRouter } from "next/navigation"; // Hook for navigating between routes
import { useCallback, useEffect, useState } from "react"; // React hooks for state and lifecycle management
import { toast } from "sonner"; // Toast notifications for success and error messages
const PollPage: React.FC<{
id: string;
}> = ({ id }) => {
const router = useRouter(); // Router instance for navigating the user after an action
const { data } = db.useQuery({ votes: {} }); // Query votes from InstantDB
const { user } = useUserStore(); // Get the current logged-in user from the store
// State variables for holding poll data, loading state, and real-time vote updates
const [poll, setPoll] = useState<PollResponse | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const pollUrl = `${process.env.NEXT_PUBLIC_API}/api/polls/${id}`; // Poll API URL
const [realTimeVotes, setRealTimeVotes] = useState<InstantDBVote[]>([]); // Holds filtered votes for real-time updates
// Function to fetch poll data from the server using the poll ID and user's token
const handleGetPoll = useCallback(
async (id: string, token: string) => {
try {
const pollResponse = await restRequest<PollResponse>({
url: `${pollUrl}?populate\[votes\][populate][0]=option&populate[options]=*`, // Fetch poll options and votes
headers: {
Authorization: `Bearer ${token}`, // Include token for authenticated requests
},
});
setPoll(pollResponse); // Update state with the fetched poll
// Filter and update the real-time votes based on the poll ID
if (data?.votes?.length)
setRealTimeVotes(
data?.votes.filter(
(vote: InstantDBVote) => vote.poll.documentId === id
)
);
setLoading(false); // Set loading to false once data is fetched
} catch (error) {
console.log("🚨🚨🚨🚨 ~ handleGetPoll ~ error", error); // Log any errors
} finally {
setLoading(false); // Ensure loading state is false even if there is an error
}
},
[data?.votes, pollUrl]
);
// Function to handle the deletion of the poll
const handleDeletePoll = (id: string, token: string) => {
toast.promise(
restRequest<PollResponse>({
url: pollUrl, // Endpoint for poll deletion
method: "DELETE", // HTTP DELETE method to remove the poll
headers: {
Authorization: `Bearer ${token}`, // Include token in the headers
},
}),
{
loading: (() => {
setLoading(true); // Set loading state while deleting the poll
return "Deleting poll..."; // Show a loading message
})(),
success: () => {
router.push("/"); // Redirect to the home page after successful deletion
return "Poll deleted successfully!"; // Show success message
},
error: (error) => {
console.log("🚨🚨🚨🚨 ~ handleDeletePoll ~ error", error); // Log error
return "An error occurred while deleting the poll."; // Show error message
},
finally: () => {
setLoading(false); // Ensure loading state is false when done
},
}
);
};
// Fetch the poll data when the component is mounted or the poll ID changes
useEffect(() => {
handleGetPoll(id, localStorage.getItem("token") || ""); // Fetch poll data with the token from localStorage
}, [handleGetPoll, id]);
// Update real-time votes whenever the vote data changes in InstantDB
useEffect(() => {
if (data?.votes?.length)
setRealTimeVotes(
data?.votes.filter((vote: InstantDBVote) => vote.poll.documentId === id)
);
}, [data, id]);
return (
<main>
<header className="site-section">
<div className="wrapper">
<h1 className="text-4xl lg:text-7xl">
{/* Display the poll question, or a fallback message if not found */}
{poll?.data.question || "No poll found"}
</h1>
{/* Display delete button if the poll belongs to the current user */}
{poll?.data && poll?.data?.user?.documentId == user?.documentId && (
<div className="action-cont">
<button
onClick={() =>
handleDeletePoll(id, localStorage.getItem("token") || "")
}
className="btn"
>
{loading ? (
<Loader className="icon" /> // Show a loading spinner while deleting
) : (
<Trash2 className="icon" /> // Show trash icon for deletion
)}
</button>
</div>
)}
</div>
</header>
<section className="site-section">
<div className="wrapper">
{loading ? (
<p>Loading...</p> // Display loading message while fetching poll data
) : (
<div>
{/* Display the poll card if poll data is available, otherwise show fallback message */}
{poll?.data ? (
<PollCard poll={poll.data} votes={realTimeVotes} type="large" />
) : (
<p>No poll found. Try again later. </p>
)}
</div>
)}
</div>
</section>
</main>
);
};
export 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
2
3
4
5
6
7
8
9
10
// ./app/poll/[id]/page.tsx
import PollPage from "@/components/Poll/Page";
const Poll = ({
params,
}: {
params: {
id: string;
};
}) => <PollPage id={params.id} />;
export 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.