Building Real-Time Serverless Games with Next.js and Neon
/ 11 min read
Table of Contents.
Introduction
In this post, I’ll walk you through the architecture of a real-time multiplayer game platform I built from scratch using the Next.js App Router, Drizzle ORM, and a serverless PostgreSQL database from Neon. The goal was to create a collection of party games that are easy to join and play without the friction of user accounts, while ensuring the backend is scalable, maintainable, and cost-effective.
It started with a simple idea: what if you could join a game with just a code and a name? This led me down the path of building a robust session management system, real-time data synchronization using polling, and a self-cleaning mechanism for inactive games. I’ll cover the code structure for two of the games, Imposter and Password, and explain how the serverless approach with Vercel and Neon makes it possible to host it for free.
This deep-dive is for anyone curious about managing game state in a serverless environment, structuring a complex application with the Next.js App Router, or building a seamless “no sign-up” user experience. We’ll explore the specific API routes, the database schema that powers the games, and the client-side logic that ties it all together.
The “No Sign-Up” Experience: Serverless Sessions
One of the core design principles was to eliminate the need for user registration. Players can join a game instantly by entering a game code and a nickname. This is powered by a lightweight, server-side session system built on top of Next.js API Routes and middleware.
When a user first visits the site, they are assigned a unique session ID, which is stored in an HttpOnly
cookie. This ID acts as their anonymous identifier across the application. When they decide to join a game, they are prompted to enter a name.
// A simplified look at how the session is initiatedconst setPlayerName = async (name: string) => { await fetch('/api/session/entered-name', { method: 'POST', body: JSON.stringify({ name }), }); // ... refresh the page to reflect the new session state};
The /api/session/entered-name
route takes the provided name and associates it with the user’s session ID in the players
table of the database. This cookie is sent with every subsequent request to the backend, allowing us to link actions like voting or sending a clue to a specific player without a traditional users
table or complex authentication. This approach makes the experience incredibly fluid and removes a major barrier to entry for a casual party game.
The session is managed via Next.js Middleware, which inspects incoming requests for the session cookie and ensures a consistent identity is maintained as the user navigates the site.
Frontend Architecture: Server and Client Components
A key advantage of using the Next.js App Router is the ability to mix Server Components and Client Components. This project leverages both to create a fast, interactive experience.
-
Server Components: Most pages, like the game lobbies (
/imposter/[id]
) and results screens, are initially rendered on the server. This allows them to fetch the initial game state directly from the database without a separate API call, reducing the time to first content. The page receives the game ID from the URL, queries the database for the game’s status, players, and other relevant data, and passes it down to Client Components as props. -
Client Components: Interactive elements, such as voting buttons, clue submission forms, and modals, are built as Client Components (
"use client"
). These components handle user input and state changes on the client-side. They are also responsible for the real-time polling, using theuseSWR
hook to fetch the latest game state and re-render the UI when data changes.
This hybrid approach provides the best of both worlds: the fast initial load times and SEO benefits of server-side rendering, combined with the rich interactivity of a client-side application.
Game Logic & API Structure
The backend for each game is organized into a series of API routes under /api/[game-name]
. This modular structure keeps the logic for each game isolated and easy to manage. The file-based routing of Next.js makes this particularly intuitive.
For example, the API for the Imposter game includes routes for:
POST /api/imposter/create
: Creates a new game lobby, generates a unique join code, and inserts it into thegames
table.GET /api/imposter/join-by-code/[code]
: Allows a player to join a game using a code.POST /api/imposter/[id]/start
: Kicks off the game, assigns roles (Imposter or Crewmate), and changes the game status.POST /api/imposter/[id]/clue
: Submits a clue during a round.POST /api/imposter/[id]/vote
: Casts a vote for the suspected imposter.
Similarly, the Password game has its own set of routes:
POST /api/password/create
: Sets up a new Password game.POST /api/password/[id]/team
: Allows players to join a team (Team A or Team B).POST /api/password/[id]/select-word
: Lets the clue-giver choose a word for the round.POST /api/password/[id]/clue
: Submits a one-word clue.POST /api/password/[id]/guess
: Submits a guess for the password.POST /api/password/[id]/end-round
: Finalizes the round and calculates scores.
This RESTful approach allows the frontend to be a clean, declarative representation of the game state that exists in the database. The client-side polls these endpoints periodically to get the latest state, creating a real-time feel without the complexity and connection management of WebSockets.
API Routes In-Depth
Let’s take a closer look at a few key API endpoints to understand the flow of data.
POST /api/imposter/create
This endpoint creates a new game. It generates a random, human-readable code for joining, creates a new entry in the games
table, and returns the new game’s ID and code.
Response
{ "id": "clxrj1t2b000008l3f4z9g9h9", "code": "32KJR3"}
POST /api/imposter/[id]/vote
This route handles a player’s vote for the imposter. It validates that the game is in the ‘voting’ stage and that the player has not already voted. The vote is then recorded in the imposterRounds
table.
Payload
{ "votedForPlayerId": "clxrk8a5c000108l3c1a9h7j7"}
The player’s session ID is read from the cookie on the server to identify the voter.
Response
A 200 OK
status on success, or a 400
/ 403
error if the vote is invalid.
GET /api/imposter/[id]
This is the main polling endpoint. It returns the complete current state of a specific game, including the list of players, game status, round details, clues, and votes. The client-side uses this data to render the entire game UI.
Response (example during voting)
{ "id": "clxrj1t2b000008l3f4z9g9h9", "status": "voting", "players": [ { "id": "session_1", "name": "Alice", "lastSeen": "..." }, { "id": "session_2", "name": "Bob", "lastSeen": "..." } ], "round": { "clues": [ { "playerId": "session_1", "clue": "Animal" }, { "playerId": "session_2", "clue": "Furry" } ], "votes": [ { "voterId": "session_1", "votedId": "session_2" } ] }, "isImposter": false, // This is specific to the requesting user "secretWord": "Corgi" // Only revealed to non-imposters}
Real-Time Updates via Polling
To keep the game state synchronized across all players’ devices, the application uses a simple yet effective short-polling mechanism. Instead of WebSockets, which can be complex to manage in a serverless architecture, the client-side periodically fetches the latest game state from a dedicated route.
On the client, a custom React hook, useSWR
, is used to poll the main game state endpoint (e.g., /api/imposter/[id]
) every couple of seconds.
// Example of using SWR for pollingimport useSWR from 'swr';
const fetcher = (url: string) => fetch(url).then((res) => res.json());
function GamePage({ gameId }) { const { data: game, error } = useSWR( `/api/imposter/${gameId}`, fetcher, { refreshInterval: 2000 } // Poll every 2 seconds );
// ... render game UI based on the fetched state}
This approach is perfectly suited for a serverless environment. Each poll is a standard HTTP request, which can be handled by a new serverless function instance. There are no persistent connections to manage, making the system horizontally scalable by default. While this introduces a slight delay compared to WebSockets, it’s negligible for the turn-based nature of these party games and dramatically simplifies the overall architecture.
Data Management with Drizzle and Neon
All game state is stored in a PostgreSQL database hosted on Neon. I chose Neon for its serverless capabilities—the database automatically scales down to zero when not in use, which is perfect for a hobby project and keeps it on the free tier.
I’m using Drizzle ORM to interact with the database. Drizzle’s schema-first, TypeScript-native approach provides excellent type safety and a developer experience that feels like writing SQL, but with the benefits of autocompletion and compile-time checks.
Here’s a more detailed look at the schema for the Imposter game:
export const games = pgTable('games', { id: text('id').primaryKey(), code: text('code').notNull().unique(), status: text('status').default('lobby'), // 'lobby', 'playing', 'voting', 'results' type: text('type').notNull(), // 'imposter' or 'password' createdAt: timestamp('created_at').defaultNow(),});
export const players = pgTable('players', { id: text('id').primaryKey(), // Session ID from cookie gameId: text('game_id').references(() => games.id), name: text('name').notNull(), lastSeen: timestamp('last_seen').defaultNow(),});
export const imposterGames = pgTable('imposter_games', { gameId: text('game_id').primaryKey().references(() => games.id), imposterId: text('imposter_id').references(() => players.id), secretWord: text('secret_word').notNull(),});
export const imposterRounds = pgTable('imposter_rounds', { id: text('id').primaryKey(), gameId: text('game_id').references(() => games.id), roundNumber: integer('round_number').notNull(), clues: jsonb('clues').default([]), // { playerId: string, clue: string }[] votes: jsonb('votes').default([]), // { voterId: string, votedId: string }[]});
This relational structure ensures data integrity. For example, when a player submits a vote, the backend can easily validate that the player is part of the game, that the game is in the ‘voting’ state, and that the player hasn’t already voted in the current round. Drizzle makes writing these queries straightforward and safe.
The Heartbeat: Keeping Games Alive (and Clean)
In a serverless environment, you can’t rely on in-memory stores or long-running processes to manage game state. A key challenge is knowing when a player has disconnected or when a game has been abandoned.
To solve this, I implemented a “heartbeat” mechanism. Each active client sends a POST
request to a /api/[game-name]/[id]/heartbeat
endpoint every few seconds.
// A simplified heartbeat hook on the clientuseEffect(() => { const interval = setInterval(() => { fetch(`/api/imposter/${gameId}/heartbeat`, { method: 'POST' }); }, 5000); // Send heartbeat every 5 seconds
return () => clearInterval(interval);}, [gameId]);
On the backend, this endpoint updates a last_seen
timestamp for the player in the database. This timestamp serves two purposes:
- Identifying active players: The UI can display which players are currently online in the lobby.
- Automatic cleanup: A separate, cron-triggered API route at
/api/cleanup
runs periodically (e.g., every 5 minutes). This route is secured and can only be invoked by a trusted scheduler like Vercel Cron Jobs.
The cleanup job performs two main tasks:
- It queries for players whose
last_seen
is older than a certain threshold (e.g., 30 seconds) and removes them from their games. - It archives or deletes games that have been inactive (no players with a recent heartbeat) for an extended period (e.g., 1 hour).
This self-cleaning system is crucial for a free-to-play, serverless application. It ensures the database doesn’t fill up with abandoned games and keeps the application running smoothly without manual intervention.
Deployment and Hosting: The Serverless Stack
This project is built to run entirely on a serverless infrastructure, which makes it highly scalable and cost-effective.
-
Hosting: The Next.js application is deployed on Vercel. Vercel’s tight integration with Next.js allows for zero-configuration deployments. Every
git push
to the main branch automatically builds and deploys the application. API routes become serverless functions, and static assets are served from a global CDN. -
Database: The PostgreSQL database is hosted on Neon. Neon’s serverless architecture means the database “scales to zero” when it’s not in use, so I only pay for what I use. For a project with intermittent traffic like a party game, this is a game-changer and keeps it well within the free tier.
-
Cron Jobs: The cleanup mechanism is powered by Vercel Cron Jobs, which trigger the
/api/cleanup
route on a schedule. This allows for periodic, automated maintenance without needing a dedicated server.
This combination of Vercel and Neon provides a powerful, scalable, and—most importantly—free hosting solution for a full-stack application like this.
Final Thoughts
Building these games has been a fantastic exploration of what’s possible with a modern, serverless web stack. By leveraging Next.js for both frontend and backend, Drizzle for type-safe database access, and Neon for a hassle-free PostgreSQL database, I was able to create a fun, real-time experience that is both scalable and completely free to host.
The “no sign-up” approach, combined with the polling-based real-time updates and the self-cleaning heartbeat system, creates a seamless and self-sustaining platform. This architectural pattern proves you don’t need complex infrastructure or stateful connections to deliver a dynamic, multiplayer user experience. Whether you’re building a party game, a collaborative tool, or any other real-time application, this serverless model is a powerful and cost-effective option.
GitHub Repository
You can view the code here: github.com/oyuh/games