Phaser and Next.js: a production-minded guide
Phaser is a solid 2D game framework for the browser. Next.js is a solid way to ship marketing pages, portfolios, and content around that game. Putting them together sounds simple until the App Router tries to server-render a canvas API that only exists in the browser, or until a route change leaves a Phaser game running in memory.
I have embedded Phaser inside Next.js projects for demos and interactive portfolio pieces. This guide covers the pattern I keep returning to: isolate the game in a client component, load it with no SSR, manage the lifecycle carefully, and keep assets boring and predictable.
Why Phaser and Next.js can fight each other
Next.js wants to render on the server first. Phaser wants window, document, and a real DOM node for the canvas. If you import Phaser at the top of a server component, the build fails or the page crashes during SSR. Even inside a client component, importing Phaser at module scope can still trip tools that evaluate modules during the build.
The fix is not "turn off React." The fix is to treat the game as a browser-only island inside an otherwise normal App Router site. Your marketing shell, SEO metadata, and blog can stay server-friendly. The game mounts only after hydration on the client.
Client-only loading with dynamic import
I keep a thin page or section that dynamically imports the game wrapper with ssr: false. That keeps Phaser out of the server bundle path.
"use client";import dynamic from "next/dynamic";const PhaserGame = dynamic(() => import("@/components/PhaserGame"), {ssr: false,loading: () => <p>Loading game…</p>,});export default function GameSection() {return (<section className="w-full"><PhaserGame /></section>);}
The loading state matters. A blank rectangle while the chunk downloads feels broken. A short message or skeleton keeps the layout stable and tells the user something is happening, especially on slower mobile connections.
Create and destroy the game on a real lifecycle
Phaser games are long-lived objects. If you create one in useEffect and forget to destroy it, route changes in the App Router will leak listeners, audio, and RAF loops. Always destroy on cleanup.
"use client";import { useEffect, useRef } from "react";import Phaser from "phaser";import { MainScene } from "@/games/MainScene";export default function PhaserGame() {const hostRef = useRef<HTMLDivElement | null>(null);useEffect(() => {if (!hostRef.current) return;const game = new Phaser.Game({type: Phaser.AUTO,parent: hostRef.current,width: 800,height: 450,backgroundColor: "#0b0f14",scene: [MainScene],scale: {mode: Phaser.Scale.FIT,autoCenter: Phaser.Scale.CENTER_BOTH,},});return () => {game.destroy(true);};}, []);return <div ref={hostRef} className="w-full max-w-4xl mx-auto" />;}
Two details I always check: the parent element must exist before construction, and destroy(true) should remove the canvas from the DOM. If you remount during React Strict Mode in development, you will see create/destroy twice. That is expected. Production mounts once.
Assets: keep paths boring
Put static assets in public/games/... and load them with absolute paths like /games/player.png. Relative imports that work in a Vite demo can break under Next route nesting. Prefer a preload scene or a single boot scene that loads everything before gameplay starts, then transition.
Watch file sizes. A cute sprite sheet that is fine on desktop can stall the first interaction on mobile data. Compress textures, avoid massive lossless PNGs when a smaller format works, and do not start audio until a user gesture if the browser blocks it.
- Host assets under
publicwith stable URLs. - Preload before gameplay; show a progress state if needed.
- Fail gracefully when an asset 404s instead of freezing.
- Test a hard refresh on a phone, not only desktop cache.
App Router pitfalls I have hit
Soft navigation is the sneaky one. Users leave the game page, the component unmounts, and if cleanup is wrong the canvas stays or the audio keeps playing. Always verify destroy on route change.
Another pitfall is putting game state into React state at 60fps. Phaser should own gameplay state. Lift only high-level events to React: score submitted, level complete, open a Next.js modal. If you sync every frame into React, you will fight the framework.
Touch controls also need intentional design. Desktop arrow keys do not translate. Build on-screen controls or swipe handlers and test them with your thumbs, not just Chrome device mode.
Finally, SEO: crawlers do not play your game. Put meaningful HTML around it — title, description, instructions, and maybe a short article about how it was built. The game can be the interactive centerpiece without being the only content on the URL.
A simple project shape that scales
I like separating concerns: a Next route for the page shell, a client wrapper for lifecycle, and a games/ folder for scenes that know nothing about React. That keeps Phaser code portable and makes the Next layer thin.
Start with one scene, one player, and one win condition. Get the mount/unmount path solid. Then add polish. Most Phaser-in-Next pain is not game design — it is lifecycle and bundling. Solve those first and the rest feels like normal JavaScript again.
If you want a portfolio piece that stands out, a small well-scoped browser game embedded cleanly in Next.js beats a large unfinished engine demo every time.
Input, audio, and mobile realities
Keyboard controls are fine for desktop demos and insufficient for most phone users. If the game is part of a portfolio meant to be shared, ship a touch path on day one: on-screen buttons, tap to jump, or simple gesture controls. Make hit areas larger than the visible art.
Audio needs a user gesture in many browsers. I gate music behind a clear Start or Mute toggle rather than autoplaying into silence or a blocked promise. When destroying the game, stop sounds explicitly so route changes do not leave a ghost soundtrack.
Orientation matters too. A landscape canvas on a portrait phone needs either a rotate prompt or a layout that still playable in portrait. Do not assume users will rotate without being asked.
Boot scene pattern I reuse
A tiny boot/preload scene keeps loading logic out of gameplay scenes and makes failure states easier to handle.
export class BootScene extends Phaser.Scene {constructor() {super("boot");}preload() {this.load.image("player", "/games/player.png");this.load.image("ground", "/games/ground.png");}create() {this.scene.start("main");}}
From main, assume assets exist. If something must be optional, check textures and branch. Silent failures with a frozen black canvas are the worst portfolio impression you can make.
Shipping checklist before you share the link
- Hard refresh, leave the route, return — no duplicate canvases.
- Throttle CPU/network and confirm the loading state appears.
- Play through on a real phone with touch controls only.
- Confirm surrounding SEO content still renders server-side.
- Check that the game chunk is code-split away from the blog.
Do that once and Phaser-in-Next stops feeling mysterious. It becomes a disciplined client island with a clean edge.
Scaling, physics, and keeping demos honest
Phaser's scale manager helps the canvas fit responsive layouts, but fitting is not the same as redesigning controls. When the game becomes letterboxed on mobile, make sure tap targets still land on the logical game space. FIT mode is a good default for portfolio embeds because it preserves aspect ratio without cropping critical HUD elements.
Be careful with physics complexity in a marketing-site embed. A demo should teach one idea quickly. Heavy simulations that stutter on mid-range phones undermine the craft story you are trying to tell. Profile on device before you add particle flair.
If you need multiple levels, drive them from a simple config object inside the games folder rather than inventing a CMS on day one. Portfolio games die from scope, not from lack of features.
Document the controls next to the canvas in HTML. Players should not have to guess. That HTML also becomes useful crawlable content around the interactive island.