iTranslated by AI
Building a Bingo App with Next.js, Pusher, and Upstash
Introduction
The other day, we had a year-end party.
A suggestion came up to do some entertainment, so we decided to play a bingo game.
I created a simple bingo app for the occasion.
This article covers how I implemented that bingo app.
Demo

As shown above, I created a screen for displaying the management number to draw numbers and a screen to display the list of drawn numbers.
Implementation
Technologies Used
Framework: Next.js (App Router)
Deployment: Vercel
Storage: Upstash For Redis
Notification Management: Pusher
I chose Next.js as the framework because it provides both backend and frontend capabilities, and it is the one I am most familiar with.
Another reason was the ability to deploy to Vercel quickly.
For storage, I initially intended to use Vercel KV given its affinity with Vercel.
However, since the documentation stated that Upstash KV should be used for new projects instead, I chose Upstash.
Incidentally, I couldn't find a service specifically named "Upstash KV." Since Vercel KV is a Redis database and Upstash's Redis offering is "Upstash For Redis," I used Upstash For Redis. (I'm not sure if this is exactly what they meant, but it worked for saving data, so I went with it.)
I will explain the reason for using Pusher for notification management later.
Architecture Diagram

The numbers generated on the admin screen are sent to Pusher, which then notifies the user screens.
Since the user screen maintains a connection with Pusher, it displays the number as soon as it receives the notification.
Drawn numbers are saved so that the list of results is still available even if the screen is reloaded.
This is a simple overview of the architecture.
Why I Used an External Service for Notification Management
For this project's requirements, I didn't feel like there were any complex notifications.
On the UI side, it's just about communicating the drawn numbers and a reset function.
Therefore, it might seem unnecessary to use an external notification service.
Server-Sent Events or WebSockets would likely have been sufficient.
However, the reason for using an external notification service this time was due to Vercel's constraints.
Vercel runs applications by executing individual Serverless Functions.
As noted in the documentation, each Serverless Function has an execution time limit.
Therefore, even if a connection was established on the server side, the connection would be lost after a certain period of time.
Re-establishing the connection every time a function starts is cumbersome. As Vercel mentions in the following article, they suggest connecting on the client side rather than establishing connections on the server.
For this reason, I chose to use Pusher, which is linked in the article above, and had the client establish the connection.
This enabled real-time sharing of the numbers.
There was no deep reason for choosing Pusher as the external service.
It was simply because I found the following easy-to-understand article when I first searched for options:
Now, let's look at the actual implementation.
I will focus on Pusher notifications and Upstash For Redis storage.
Please note that I won't be covering the logic for generating the numbers themselves.
Implementation Details
Prerequisites
It is assumed that you have already registered for a Vercel account and that a Next.js project has been created and deployed.
Preparing the Storage
First, click the "Install" button for Upstash on the page displayed at the following URL:
Then, you will be able to select the project to link with Vercel, so link the project you created.

Once the linking is complete, click "Install Upstash For Redis."

Next, after selecting the primary region you'll use, you will be able to configure the plan. If you want to use it for free, select the Free plan.
The database creation results and environment variables will then be displayed, so add them to your project's .env file.
The project should already be linked, but if the linking isn't configured in the "Storage" tab within the project, configure the connection settings in that tab.

This completes the setup.
Preparing to operate the storage
As mentioned earlier, add the secret information displayed when you created Upstash For Redis to your environment variable file.
And then, install @upstash/redis.
After installation, add the following to any file:
import { Redis } from "@upstash/redis";
const redis = new Redis({
url: process.env.KV_REST_API_URL,
token: process.env.KV_REST_API_TOKEN,
})
export const db = {
upsert: async ({ id, data }: { id: string, data: string }) => {
const result = await redis.get<string | null>(id)
if (!result) {
return await redis.set(id, JSON.stringify([data]))
}
if (Array.isArray(result)) {
return await redis.set(id, JSON.stringify([...result, data]))
}
}
,
getById: async (id: string): Promise<string[]> => {
const result = await redis.get<string | null>(id)
if (!result) {
return []
}
if (Array.isArray(result)) {
return result as string[];
}
throw new Error('Invalid data') // 不正なデータです
},
delete: async (id: string) => {
return await redis.del(id)
}
}
Since I want to handle numbers as an array this time, I'm performing operations as such.
I assume id uses /:id from the URL.
This is to avoid values being shared across rooms and causing strange behavior.
With this ready, I called it within a Route Handler as follows to save and retrieve data.
import { NextRequest, NextResponse } from 'next/server';
import path from 'path';
import { db } from '@/lib/db';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const gameId = searchParams.get('gameId');
if (!gameId) {
return NextResponse.json({ error: 'Invalid URL' }, { status: 400 }); // 誤ったurl
}
const fileData = await db.getById(gameId)
return NextResponse.json({
fileData
});
}
export async function POST(request: NextRequest) {
try {
const { number, gameId } = await request.json();
/** Omitted */ // 省略
await db.upsert({ data: number.toString(), id: gameId })
return NextResponse.json({
success: true,
message: 'Number announced successfully',
number
});
} catch (error) {
console.error('Error in number announcement:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
Preparing Pusher
Now that we've covered storage, let's move on to notifications with Pusher.
First, regarding Pusher setup, you can complete it by following the article at the URL below, so I will only share it here.
Configuring Pusher
Once the Pusher setup is complete, set the secrets in your environment variable file.
After that, install the pusher and pusher-js modules.
Once installed, add the following to any file:
import PusherServer from 'pusher';
import PusherClient from 'pusher-js';
export const pusherServer = new PusherServer({
appId: process.env.PUSHER_APP_ID!,
key: process.env.NEXT_PUBLIC_PUSHER_KEY!,
secret: process.env.PUSHER_SECRET!,
cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
useTLS: true,
});
export const pusherClient = new PusherClient(
process.env.NEXT_PUBLIC_PUSHER_KEY!,
{
cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
}
);
I created pusherServer for sending values to Pusher and pusherClient for establishing a connection with Pusher.
Let's look at how each is used.
The pusherServer, which sends values to Pusher, is called within a Route Handler as follows:
import { pusherServer } from '@/lib/pusher';
/** Omitted */
export async function POST(request: NextRequest) {
const { number, gameId } = await request.json()
await pusherServer.trigger(
`bingo-game-${gameId}`,
'new-number',
{
number,
timestamp: new Date().toISOString()
}
);
/** Omitted */
}
When sending values to Pusher, the trigger method is used.
The first argument specifies the channel name for communication.
The second argument specifies the event name to be used within the channel.
The third argument sends the actual data to be exchanged.
This allows you to pass the information for sending notifications to Pusher.
After that, Pusher notifies the clients that have established a connection to the specified channel.
Specifically, it is implemented like this:
'use client'
import { useEffect } from 'react';
import { pusherClient } from '@/lib/pusher';
export function BingoBoard({ gameId }: { gameId: string }) {
const [numbers, setNumbers] = useState<number[]>([]);
const [currentNumber, setCurrentNumber] = useState<number | null>(null);
useEffect(() =>
// Specify the channel name to connect to
const gameChannel = pusherClient.subscribe(`bingo-game-${gameId}`);
// Receive events within the channel and specify a callback
gameChannel.bind('new-number', (data: { number: number }) => {
setCurrentNumber(data.number)
setTimeout(() => {
setNumbers(prev => [...prev, data.number]);
}, 3500);
});
// Unsubscribe from the connection
return () => {
pusherClient.unsubscribe(`bingo-game-${gameId}`);
};
}, [gameId]);
/** Omitted */
}
You use the subscribe method of the pusherClient prepared earlier to specify the channel you want to connect to.
Then, you wait for events communicated within that channel and register a callback to be executed when an event is detected.
This enables the frontend to receive notifications from Pusher.
My actual impression after trying it was that it was very easy.
Once the project is set up and the module for interacting with Pusher is installed, it's almost done, and I was able to implement it without stress.
Also, since you can exchange 200,000 messages a day and connect up to 100 devices simultaneously for free, it was perfectly fine for a small-scale app like this one.
That's all for the main features of the bingo app.
If you'd like to see the full code, I've provided a link below. (Note: There is quite a bit of unnecessary code and it's rather messy. Please be understanding.)
Digression: About Animations
I was also asked how the bingo number animation works, but for this part, I just had generative AI work really hard on it.
So, I won't explain the implementation; I'll just leave the component code here.
import React, { useState, useEffect, useCallback } from 'react';
export const SpineMachine = ({ finalNumber = 42, duration = 3000 }) => {
const [isSpinning, setIsSpinning] = useState(false);
const [currentNumber, setCurrentNumber] = useState('00');
const [drumRotation, setDrumRotation] = useState(0);
const [ballPosition, setBallPosition] = useState({ x: 100, y: 120 });
const [showBall, setShowBall] = useState(false);
const spin = useCallback(() => {
setIsSpinning(true);
setShowBall(false);
setBallPosition({ x: 100, y: 120 });
}, []);
useEffect(() => {
spin()
}, [finalNumber])
useEffect(() => {
if (!isSpinning) return;
let startTime: number | null = null;
let animationFrame: number | null = null;
const animate = (timestamp: number) => {
if (!startTime) startTime = timestamp;
const progress = timestamp - startTime;
const progressRatio = Math.min(progress / duration, 1);
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
const currentSpeed = easeOut(1 - progressRatio);
const rotationIncrement = currentSpeed * 30;
setDrumRotation(prev => (prev + rotationIncrement) % 360);
if (progress > duration - 1000) {
setShowBall(true);
const fallProgress = (progress - (duration - 1000)) / 1000;
// Calculate natural fall curve
const startX = 155;
const startY = 160;
const endX = 100;
const endY = 270;
// Fall considering gravitational acceleration
const x = startX + (endX - startX) * fallProgress;
const y = startY + (endY - startY) * (fallProgress * fallProgress);
setBallPosition({ x, y });
if (progress > duration - 500) {
setCurrentNumber(String(finalNumber).padStart(2, '0'));
}
} else if (progress % (16 / currentSpeed) < 16) {
setCurrentNumber(String(Math.floor(Math.random() * 100)).padStart(2, '0'));
}
if (progress < duration) {
animationFrame = requestAnimationFrame(animate);
} else {
setIsSpinning(false);
setCurrentNumber(String(finalNumber).padStart(2, '0'));
}
};
animationFrame = requestAnimationFrame(animate);
return () => {
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
};
}, [isSpinning, duration, finalNumber]);
return (
<div className="flex flex-col items-center justify-center " style={{ marginBottom: 0, marginTop: 0 }}>
<div className="relative w-44 h-64">
<svg viewBox="0 0 200 300" className="w-full h-full">
{/* Base */}
<rect x="40" y="200" width="120" height="80" fill="#2A4365" rx="5" />
<rect x="45" y="205" width="110" height="70" fill="#3B82F6" rx="3" />
{/* Main drum */}
<g transform="translate(100 120)">
<g transform={`rotate(${drumRotation})`}>
<circle cx="0" cy="0" r="60" fill="#1E40AF" />
<circle cx="0" cy="0" r="55" fill="#2563EB" />
{/* Internal partitions */}
{[...Array(12)].map((_, i) => (
<g key={i} transform={`rotate(${i * 30})`}>
<line
x1="0"
y1="-55"
x2="0"
y2="-35"
stroke="#fff"
strokeWidth="3"
className={isSpinning ? 'opacity-50' : 'opacity-100'}
/>
</g>
))}
</g>
</g>
{/* Exit hole */}
<g transform="translate(155, 160)">
{/* Hole outer frame */}
<circle cx="0" cy="0" r="8" fill="#1E3A8A" />
{/* Inside of the hole (depth representation) */}
<circle cx="0" cy="0" r="6" fill="#152C61" />
</g>
{/* Tray */}
<path
d="M70,260 C70,250 130,250 130,260 L130,270 C130,280 70,280 70,270 Z"
fill="#2A4365"
stroke="#1E3A8A"
strokeWidth="1"
/>
{/* Glass dome */}
<path
d="M40,120 A60,60 0 0,1 160,120 L160,200 L40,200 Z"
fill="rgba(255,255,255,0.1)"
stroke="#A3BFFA"
strokeWidth="2"
/>
{/* Bingo ball falling */}
{showBall && (
<g transform={`translate(${ballPosition.x} ${ballPosition.y})`}>
<circle r="6" fill="white" stroke="#2563EB" strokeWidth="1">
<animate
attributeName="r"
values="6;5.5;6"
dur="0.5s"
repeatCount="indefinite"
/>
</circle>
<text
textAnchor="middle"
dy="3"
fontSize="8"
fill="#2563EB"
fontWeight="bold"
>
{currentNumber}
</text>
</g>
)}
</svg>
{/* Final display of the drawn ball */}
{showBall && ballPosition.y > 260 && (
<div className="absolute bottom-6 left-0 w-full flex justify-center">
<div className="bg-white rounded-full w-12 h-12 flex items-center justify-center shadow-lg border-2 border-blue-600 animate-bounce">
<span className="font-mono font-bold text-xl text-blue-600">
{currentNumber}
</span>
</div>
</div>
)}
</div>
</div>
);
};
Since I had generative AI put in quite a bit of effort for this, seeing articles where people write these things themselves makes me respect them all over again.
Why I made the bingo app
We've looked at the implementation, but let me also talk about why I made the bingo app in the first place.
There are many bingo apps out there, such as BINGO! Online (https://bingo-online.jp/).
So, there's no need to reinvent the wheel—if it's just a pure bingo app, that is.
Actually, in this bingo app, the numbers for the first 9 draws are predetermined.
So, winning cards were prepared beforehand.
You still need the luck to pick a winning card, but once you pull it, you win immediately.
I created the bingo app specifically to do this.
Among those who discussed this with me, we called it "Match-Pump Bingo" (staged bingo).
In reality, I heard voices around those who won in a straight line saying it was "immense luck," so it went exactly as planned.
Another reason was that I wanted to include a small joke, like showing a 500 error—familiar to engineers?—as shown below.

Since I was able to do everything I wanted to do, I am personally satisfied.
Vercel was surprisingly resilient to load
This time, since the event was held with over a dozen people accessing it simultaneously, I was quite worried about the load.
However, I was surprised that the application moved without any problems and didn't feel heavy at all.
Therefore, if I have the opportunity in the future, I would like to restore the features I stripped away considering the load and create an even more "match-pump" bingo app. (If I ever have the chance to use a bingo app again, that is...)
Thank you for reading this far.
Discussion