EpicNext-CMS v1.0.1
A modern, high-performance content management system for Habbo hotel emulators, built on Next.js 16 (App Router) with Drizzle ORM and React 19. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases.
Features a premium animated homepage (typewriter hero, floating orbs, scroll counters), a full admin panel, NextAuth authentication (argon2id hashing with legacy md5/bcrypt auto-upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment.
System Requirements
| Component | Version | Notes |
|---|---|---|
| Node.js | 26.8.1 | Current release pinned in .nvmrc |
| pnpm | >= 11.25.0 | Recommended package manager |
| npm | >= 11.x | Supported alternative |
| yarn | >= 4.x | Supported alternative |
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
| Docker | 24+ | Optional — for containerized deployment |
| DragonflyDB | 1.x+ | Optional — caching, rate limiting, SSE |
Quick Start
1. Clone & Install
Choose your preferred package manager:
git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git
cd EpicNext-Cms
pnpm (recommended):
pnpm install
npm:
npm install
yarn:
yarn install
2. Database Setup
The CMS shares a database with the Polaris/Arcturus emulator. Use an existing database or create a new one:
CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
The CMS reads emulator-owned tables (users, items, rooms, bans, etc.) directly. It never creates, alters, or drops them. The Drizzle schema in src/db/schema.ts is generated from the existing database structure and does not modify it.
Note: The CMS does not own the emulator schema — it maps to those tables via Drizzle. Never run
drizzle-kit push/migrateagainst the shared DB. CMS-owned tables (website_*,radio_*, etc.) are created via idempotent SQL indrizzle/migrations/.
3. Configure Environment
cp .env.example .env
Edit .env with at minimum:
DATABASE_URL=mysql://user:[email protected]:3306/epicnext_cms
AUTH_SECRET=<random 32+ character string>
HOTEL_NAME=YourHotel
APP_URL=http://localhost:3002
See .env.example for all optional variables (RCON, email, DragonflyDB, OAuth, PayPal, etc.).
4. Run CMS Migrations
# pnpm
pnpm db:migrate
# npm
npm run db:migrate
# yarn
yarn db:migrate
Creates all CMS-owned tables (website_*, radio_*, acl_*, admin_audit_log, etc.) via idempotent SQL files in drizzle/migrations/. Emulator tables are never touched.
5. Build & Start
# Development (hot reload)
pnpm dev # or: npm run dev / yarn dev
# Production
pnpm build && pnpm start # or: npm run build && npm start
Open http://localhost:3002 in your browser.
6. First Login
- Register an account at
/register, or log in with an existing emulator account. - Grant yourself admin access:
UPDATE users SET rank = 7 WHERE username = 'yourname'; - Visit
/adminand configure your hotel via Admin → CMS Settings.
Docker Deployment
The CMS ships with a multi-stage Dockerfile that automatically detects your package manager (pnpm, npm, or yarn) based on which lockfile is present. The CMS runs in Docker; the Nitro/Octane client stays outside and is served by nginx on the host. The server-side avatar imager also runs in its own Docker container (avatar-imaging-pixinode, port 8082) — see Avatar Imaging.
Prerequisites
- Docker 24+ and Docker Compose v2
.envfile configured (see step 3 above)- A MySQL/MariaDB database reachable from the container (
DATABASE_URLhost should point to the DB server, not127.0.0.1unless it's reachable from inside the container) - (Optional) A shared
Gamedatadirectory at/var/www/Gamedatawith write access (see Volumes below)
Quick Start with Docker
# 1. Clone and configure
git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git
cd EpicNext-Cms
cp .env.example .env
# Edit .env with your database credentials and settings.
# The container runs on the host network, so all 127.0.0.1 references
# (DATABASE_URL, REDIS_URL, RCON, imaging) keep pointing at the host as-is.
# 2. Stop any existing host-side CMS that occupies port 3002 (if present).
pm2 stop next 2>/dev/null || true
# 3. Ensure the write directories are owned by www-data (UID/GID 33) so the
# container user can write imports/uploads to them.
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage
# 4. Build and start
docker compose up -d --build
# 5. Run migrations. The slim runtime image has no source/tsx, so run migrations
# on the HOST (against the same database) before/after starting the container.
pnpm db:migrate # or: npm run db:migrate / yarn db:migrate
# 6. Check logs
docker compose logs -f cms
The CMS will be available at http://localhost:3002.
Reverse proxy: This setup is designed to run behind the existing nginx on the host. nginx serves the Nitro/Octane client (
/client/,/nitro-client/) and/gamedata/directly from/var/www/Octane/distand/var/www/Gamedata, and proxies/to the CMS on127.0.0.1:3002. PointAPP_URL/NEXT_PUBLIC_APP_URLat the public site URL.
Automatic Updates
Updating is fully scripted — no need to touch Docker or nginx by hand. The
script scripts/docker-update.sh pulls the latest main, runs CMS migrations
on the host, rebuilds the image, recreates the container and waits for a healthy
status. It also makes sure a stale host-side PM2 CMS (pm2 stop next) stays
stopped so it can't clash on port 3002.
Automatically (recommended): a nightly cron job is already configured on a
production server that installed this setup. It runs the script every night at
03:30 and appends to logs/docker-update.cron.log:
crontab -e
# 30 3 * * * /var/www/atom-nexst/scripts/docker-update.sh >> /var/www/atom-nexst/logs/docker-update.cron.log 2>&1
Manually — to update right now (same steps as the cron runs):
cd /var/www/atom-nexst
./scripts/docker-update.sh
# log: logs/docker-update.log
The script aborts safely (exit 1) if the working tree has uncommitted changes so
a git pull can never clobber local edits, and leaves the container running if
health fails so you can debug it (exit 3). Failed runs are reported in the log;
an exit of 0 means the CMS is healthy on the new commit.
Docker Commands
| Command | Description |
|---|---|
docker compose up -d --build |
Build and start in background |
docker compose down |
Stop and remove containers |
docker compose logs -f cms |
Follow CMS logs |
docker compose exec cms sh |
Open a shell in the CMS container |
docker compose restart cms |
Restart the CMS container |
docker compose pull && docker compose up -d --build |
Update and redeploy |
pnpm db:migrate (on the host) |
Run database migrations (slim image has no source) |
./scripts/docker-update.sh |
Full automated update (manual or cron) |
How Package Manager Detection Works
The Dockerfile checks for lockfiles in this order:
pnpm-lock.yaml→ uses pnpm (fastest, recommended)yarn.lock→ uses yarnpackage-lock.json→ uses npm- No lockfile → falls back to
npm install
This means you can use any package manager on your host machine — the Docker build will automatically match.
Override the detection explicitly with
docker compose build --build-arg PACKAGE_MANAGER=pnpm(ornpm/yarn).
Volumes
Only the CMS (and the optional avatar imager container, see Avatar Imaging) run in Docker. The heavy client assets and gamedata stay on the host and are shared into the container so imports and uploads persist:
| Container Path | Host Path | Mode | Purpose |
|---|---|---|---|
/app/public/nitro-assets |
./public/nitro-assets |
rw | Imported furni/pet/effect assets |
/app/public/swf |
./public/swf |
rw | SWF costumes / icons (imports) |
/app/storage |
./storage |
rw | Uploaded media (persistent) |
/var/www/Gamedata |
/var/www/Gamedata |
rw | Shared gamedata root (hardcoded path) |
About the hardcoded /var/www/Gamedata path: src/lib/services/furni-asset-dirs.ts defines DEFAULT_GAMEDATA_ROOT = /var/www/Gamedata as an absolute on-disk path the CMS reads and mirrors imported assets into. The compose file mounts the host /var/www/Gamedata at the identical path inside the container so existsSync('/var/www/Gamedata') succeeds and nginx keeps serving /gamedata/ from the same directory.
The Nitro/Octane client and renderer are NOT mounted — nginx on the host serves them directly:
| Component | Host path | How it's served |
|---|---|---|
| Nitro/Octane client | /var/www/Octane/dist |
nginx location ^~ /client/ and /nitro-client/ alias |
| Avatar imager | /docker/Polaris-imager |
Docker container avatar-imaging-pixinode on port 8082 — see Avatar Imaging |
| Camera uploads | /var/www/Camera |
nginx /camera/ alias |
Container user & write permissions: the CMS container runs as www-data (UID/GID 33) by default to match the host owner of /var/www/Gamedata. Ensure the other write volumes (./public/nitro-assets, ./public/swf, ./storage) are also owned by www-data on the host:
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage
sudo chmod -R o+rX ./public/nitro-assets ./public/swf ./storage
If your host user UID differs, override the run user at build time (defaults to www-data, which already exists in the base image with UID/GID 33):
docker compose build --build-arg RUN_USER=1000
Networking
The CMS container runs with network_mode: host. This lets the existing 127.0.0.1 references in .env keep working against host services — MariaDB (3306), DragonflyDB/Redis (6379), the emulator RCON/API (3003/3001) and the avatar imager — without re-writing any environment variables. The container consequently listens directly on the host's port 3002, so stop any previous host-side CMS (e.g. pm2 stop next) that occupies that port before starting it.
The build also runs on the host network (build.network: host) because this host disables Docker iptables (/etc/docker/daemon.json: "iptables": false), which would otherwise leave build containers without outbound NAT/DNS when fetching packages.
Health Check
The container includes a health check that hits /api/health on port 3002 every 30 seconds (and reports DB/Redis/emulator status). Check status with:
docker inspect --format='{{.State.Health.Status}}' epicnext-cms
ORM Setup & Type Generation
Drizzle ORM (Primary Data Layer)
Drizzle ORM is the runtime data layer. The connection is a singleton in src/lib/db.ts:
import { db } from "@/lib/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm/expressions";
const found = await db.select()
.from(users)
.where(eq(users.username, "hello"));
Drizzle CLI (drizzle-kit) is used for local development tasks — it is a devDependency and is never bundled in production.
| Command | What it does |
|---|---|
pnpm db:generate |
Draft SQL from Drizzle schema into drizzle/drafts/ (review + copy) |
pnpm db:studio |
Open Drizzle Studio (dev only) |
pnpm db:introspect |
Reverse-engineer an existing DB into a Drizzle schema draft |
pnpm db:schema:generate |
Regen committed src/db/schema.ts from previous schema + live DB |
The CMS does not use
drizzle-kit pushordrizzle-kit migrate— the database is shared with the emulator. Apply CMS DDL only viapnpm db:migrate.
DragonflyDB (Caching, Rate Limiting, SSE)
DragonflyDB is a drop-in, Redis-compatible in-memory datastore. The CMS connects to it via REDIS_URL using the ioredis client. It is optional: without it the CMS falls back to in-process memory.
Install (Ubuntu/Debian)
curl -fsSL -o /tmp/dragonfly_amd64.deb \
https://github.com/dragonflydb/dragonfly/releases/download/v1.40.1/dragonfly_amd64.deb
apt-get install -y /tmp/dragonfly_amd64.deb
systemctl enable --now dragonfly
Configure
Edit /etc/dragonfly/dragonfly.conf:
--bind=127.0.0.1
--port=6379
--maxmemory=2gb
--version_check=false
Point the CMS at it
REDIS_URL=redis://127.0.0.1:6379?connect_timeout=2
Verify via /api/health — it should report "redis": true.
Nginx Configuration
The CMS is designed to run behind an nginx reverse proxy. Below is a reference configuration.
Prerequisites
- SSL certificates in
/etc/ssl/cert.pemand/etc/ssl/key.pem(or use Let's Encrypt) - CMS running on
127.0.0.1:3002
Reference Configuration
proxy_cache_path /var/cache/nginx/html_cache levels=1:2 keys_zone=html_cache:50m max_size=500m inactive=10m use_temp_path=off;
server {
listen 80;
server_name yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name yourdomain.com;
ssl_certificate /etc/ssl/cert.pem;
ssl_certificate_key /etc/ssl/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
client_max_body_size 20m;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
location / {
proxy_pass http://127.0.0.1:3002;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_cache html_cache;
proxy_cache_valid 200 60s;
proxy_ignore_headers Vary;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
add_header X-Cache-Status $upstream_cache_status always;
}
location /api/ {
proxy_pass http://127.0.0.1:3002;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
location /_next/static/ {
proxy_pass http://127.0.0.1:3002;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location /imaging/ {
proxy_pass http://127.0.0.1:8082/;
proxy_set_header Connection "";
proxy_http_version 1.1;
add_header Cache-Control "public, max-age=300";
}
location /health {
access_log off;
return 200 "OK";
add_header Content-Type text/plain;
}
}
/imagingtrailing-slash gotcha:location /imaging(no trailing slash) combined withproxy_pass http://…8082/;rewrites/imaging/avatarimageinto//avatarimageand 404s. Always uselocation /imaging/+ a trailing-slashproxy_passso/imaging/avatarimagereaches the imager's/avatarimageroute.
Avatar Imaging
Avatar images (profile figures, chat badges, forum avatars, admin previews) are rendered server-side by Polaris-imager (duckietm/Polaris-imager) — a headless Nitro/Octane renderer that runs @pixi/node (real WebGL via gl + Xvfb) inside a Docker container named avatar-imaging-pixinode. It serves GET /avatarimage?figure=… on port 8082 and can emit PNG/APNG/GIF, gestures, actions, scenes and more.
Request flow
browser → Cloudflare → (page rule /origin for /imaging/*) → host:8082
browser → nginx origin → location /imaging/ → 127.0.0.1:8082/avatarimage
URLs look like https://<hotel>/imaging/avatarimage?figure=hd-180-1.ch-210-66&img_format=png&size=l&direction=4. The public NEXT_PUBLIC_IMAGER_URL points at that endpoint (see src/lib/imager.ts). Responses carry Cache-Control: public, max-age=300, so browsers and Cloudflare reuse a render for 5 minutes.
Requirements (host)
- Docker (daemon with
build.network: host, same as the CMS build — this host disables Docker iptables). - An nginx that serves
/gamedata/(FigureData/FigureMap/EffectMap…) and/gamedata/bundled(.nitroassets) over HTTP so the renderer can fetch them. The compose file points athost.docker.internal:8081.
Configuration — /docker/Polaris-imager/.env
NITRO_GAMEDATA_URL=http://host.docker.internal:8081/gamedata/config
NITRO_ASSET_URL=http://host.docker.internal:8081/gamedata/bundled
AVATAR_IMAGING_FPS=12
AVATAR_IMAGING_MAX_FRAMES=60
AVATAR_IMAGING_HOST=0.0.0.0
AVATAR_IMAGING_PORT=8082
AVATAR_IMAGING_RATELIMIT_MAX=600
AVATAR_IMAGING_SCENE=1
AVATAR_IMAGING_WARDROBE=1
AVATAR_IMAGING_HABBO_FONTS=1
AVATAR_IMAGING_CHAT_BUBBLES=1
AVATAR_IMAGING_HOTEL_URL=https://<hotel>
AVATAR_IMAGING_HOTEL_NAME=<hotel>
Build & start
The image is self-contained — the Dockerfile clones the pinned Nitro/Octane renderer and runs the full yarn/vite bundle at build time, then produces a slim Debian runtime with Xvfb + Mesa software rendering:
cd /docker/Polaris-imager
docker compose build
docker compose up -d # container: avatar-imaging-pixinode
docker logs -f avatar-imaging-pixinode # expect: "listening on http://0.0.0.0:8082"
The container runs restart: unless-stopped and ships a healthcheck that probes /health (reports { "ready": true } once the headless renderer has booted). Verify a render:
curl -o avatar.png 'http://127.0.0.1:8082/avatarimage?figure=hr-893-45.hd-600-1.ch-255-66.lg-280-110.sh-295-62&img_format=png&size=l'
A production issue that this section documents: the avatar-imaging-pixinode image can be removed by a docker image prune, which takes the whole site's avatars offline (502 on /imaging/*). Keep the image tagged and re-build it via the commands above if it ever disappears (docker compose build && docker compose up -d).
Figure string validation (src/app/api/imaging/avatar/route.ts)
The CMS-side route re-validates the incoming figure against /^[a-z]{2}-\d+(-\d+)?(\.[a-z]{2}-\d+(-\d+)?)*$/i. Real Habbo figure strings use type-id-color parts separated by dots (hd-180-1.ch-210-66), so each part allows an optional second number. Invalid input is rejected with 400 before any render happens.
Performance Tuning (production)
The CMS shares its MySQL/MariaDB database with the emulator, so the hot wins come from caching, asset serving and database knob-tuning — not from a storage-engine migration.
1. gzip_static pre-compression of gamedata JSON
The client downloads FurnitureData.json (~48 MB, one file per supported language) plus other large JSON from /gamedata/. nginx has gzip and gzip_static on (see /etc/nginx/nginx.conf), but with no pre-built .gz file it re-compressed the 48 MB on every request (~0.25–0.30 s of CPU per miss).
scripts/compress-gamedata.mjs pre-compresses every JSON >1 MB in <Gamedata>/config and <Gamedata>/bundled/config once (gzip level 9): FurnitureData*.json go from ~48 MB to ~2.4 MB (~95 % smaller). It is idempotent — a file is only re-compressed when the source mtime is newer than its .gz (e.g. after a Studio catalog export).
pnpm gamedata:compress # manual run
Once the .gz exists, nginx gzip_static serves it straight from disk with Content-Encoding: gzip and effectively zero CPU. Measured result on the production origin: 0.25–0.30 s → ~0.005 s per file.
The production host runs the script on a cron so newly exported catalogs are compressed shortly after they land:
crontab -e
# */15 * * * * node /var/www/atom-nexst/scripts/compress-gamedata.mjs >> /var/www/atom-nexst/logs/gamedata-compress.log 2>&1
2. MariaDB tuning
The whole habbo datadir is roughly 0.5 GB; the default InnoDB buffer pool (128 MB) pushed index/row reads to disk. The pool is raised to 4 GB and the slow-query log is enabled (threshold 2 s), both live (SET GLOBAL …) and persisted for boot in /etc/mysql/mariadb.conf.d/zz-cms-performance.cnf:
[mysqld]
innodb_buffer_pool_size = 4G
slow_query_log = 1
long_query_time = 2
slow_query_log_file = /var/log/mysql/mariadb-slow.log
The slow-query log surfaces hot-spot SQL for a follow-up index/query audit. The CMS connection pool itself is already tuned (pool size 25, connection_limit in DATABASE_URL).
3. Asset caching headers
| Path | Cache-Control |
|---|---|
/swf/**, /nitro-assets/** |
public, max-age=604800, stale-while-revalidate=2592000 |
/gamedata/**, /client/** |
public + expires 7–30 d (nginx) |
/imaging/* |
public, max-age=300 |
/camera/** |
public, max-age=31536000, immutable |
/_next/static/** |
public, max-age=31536000, immutable |
4. Cache layers (Redis + CDN)
The CMS caches through cached() / cachedQuery() (src/lib/cache.ts): an in-memory fast path with a Redis (DragonflyDB-compatible) fallback and single-flight protection against cache stampedes. Public API routes (home, leaderboard, online count, radio, photos, …) fill Redis keys per route; verify with redis-cli DBSIZE. With Cloudflare in front, static and /imaging/* responses are additionally cached edge-side (cf-cache-status), cutting origin load further.
Production Deployment (PM2)
pnpm build
pm2 start pnpm --name "next" -- start
pm2 save
Restart after updates:
git pull
pnpm install
pnpm build
pm2 restart next
Scripts
| Command | Description |
|---|---|
pnpm dev |
Start development server (hot reload) |
pnpm build |
Production build |
pnpm start |
Start production server |
pnpm typecheck |
Run TypeScript type checking |
pnpm test |
Run all tests (Vitest) |
pnpm db:migrate |
Apply pending SQL migrations |
pnpm db:migrate:status |
Show migration status |
pnpm db:schema:generate |
Regen src/db/schema.ts from prior schema + live DB |
pnpm db:generate |
Draft SQL via drizzle-kit → drizzle/drafts/ |
pnpm db:studio |
Drizzle Studio (dev) |
pnpm db:introspect |
drizzle-kit introspect (draft) |
pnpm gamedata:compress |
Pre-compress large gamedata JSON to .gz (gzip_static) |
pnpm analyze |
Build + open bundle analyzer |
pnpm jobs:worker |
Start background task worker |
pnpm biome:check |
Lint and format code |
Replace
pnpmwithnpm runoryarnfor other package managers.
Performance Features
| Feature | Description |
|---|---|
| React Compiler | Automatic memoization — reduces unnecessary re-renders |
| View Transitions API | Native browser transitions between page navigations |
| Lenis Smooth Scroll | Fluid, customizable scrolling |
| Server-Sent Events | Real-time radio now-playing & listeners via SSE |
| DragonflyDB Caching | Caches API responses up to 30s in DragonflyDB |
| RCON (TCP Socket) | Live commands to the emulator (credits, badges, kick, ban) |
| Streaming & Suspense | Next.js App Router streaming for fast page loads |
| Automatic Image Optimization | next/image with Sharp for resizing and WebP/AVIF |
| Compression | Gzip compression enabled on all responses |
| gzip_static (gamedata) | 48 MB FurnitureData JSON served from pre-built .gz — no per-request CPU |
| PWA | Service worker with skip-waiting, stale CSS chunk recovery |
| Biome | Fast linting and formatting (replaces ESLint + Prettier) |
Architecture
├── drizzle/
│ ├── migrations/ # CMS SQL migrations (idempotent, tracked in cms_migrations)
│ └── drafts/ # drizzle-kit generate output (never auto-applied)
├── scripts/
│ ├── apply-migrations.ts # SQL migration runner (apply + status)
│ ├── jobs-worker.ts # Background task scheduler
│ ├── generate-drizzle-schema.mjs # Regen src/db/schema.ts from schema + live DB
│ └── compress-gamedata.mjs # Pre-compress large gamedata JSON (gzip_static)
├── src/
│ ├── db/
│ │ ├── schema.ts # Drizzle ORM schema (committed — runtime data layer)
│ │ └── relations.ts # Drizzle relations
│ ├── app/ # Next.js App Router (pages & API routes)
│ ├── actions/ # Server Actions
│ ├── components/ # UI components
│ ├── lib/
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
│ │ ├── db.ts # Drizzle connection singleton (runtime)
│ │ ├── redis.ts # Cache client (ioredis → DragonflyDB)
│ │ └── motion.ts # Framer Motion animation variants
│ ├── messages/ # i18n translations (en, nl, de, fr, es, it, pt, da, no, sv)
│ └── env.ts # Zod-validated environment schema
├── public/
│ ├── assets/ # Images, icons, fonts
│ ├── nitro-assets/ # Nitro client assets (~3GB, volume-mounted in Docker)
│ └── swf/ # SWF client files (volume-mounted in Docker)
├── docker-compose.yml # Docker Compose configuration
├── Dockerfile # Multi-stage, multi-package-manager Docker build
├── next.config.ts # Next.js configuration
└── .env.example # Environment template with all options
Optional Integrations
Emulator — RCON
For live actions (credits, badges, rank changes, kick, ban, hotel alerts). The emulator must be running with RCON enabled at RCON_HOST:RCON_PORT.
Client — Nitro
The /client page loads the Nitro client. Configure the client URL via Admin → CMS Settings (nitro_client_url). See update-Nitrov3.sh for deployment.
Radio
Supports any streaming radio (e.g., Azuracast). Configure endpoints via CMS Settings:
radio_now_playing_api_urlradio_listeners_api_url
The radio player uses SSE for real-time updates (10s interval, no polling).
Background Jobs
Run as a persistent process:
pnpm jobs:worker # or: npm run jobs:worker / yarn jobs:worker
AI Content Moderation
Optional OpenAI-powered moderation for comments and guestbook posts. Set OPENAI_API_KEY.
FlareSolverr (Cloudflare Bypass)
Some clone sources are protected by Cloudflare. FlareSolverr solves these challenges automatically.
docker compose up -d flaresolverr
FLARESOLVERR_URL=http://localhost:8191
Furniture Import with Auto-Translation
The CMS automatically translates furniture names and descriptions to 13 languages on every import:
- Native languages (via official Habbo gamedata): Dutch, English, German, French, Spanish, Turkish, Italian
- Additional languages (via LibreTranslate Docker at
127.0.0.1:5000): Portuguese, Finnish, Polish, Russian, Arabic, Japanese
Theming
12 preset themes with fully customizable colors via Admin → Theme (/admin/theme).
Development
pnpm dev # Start with hot reload
pnpm typecheck # Type check all files
pnpm test # Run test suite
pnpm analyze # Build and analyze bundle sizes
pnpm biome:check # Lint and format
Contributing
- Ensure typecheck and tests pass:
pnpm typecheck && pnpm test - Follow existing code conventions (Server Components where possible)
- SQL migrations in
drizzle/migrations/must be idempotent - For new database code, use the Drizzle runtime directly (
import { db } from "@/lib/db") - Avoid
any— usebiome-ignorecomments only when unavoidable
License
CC BY-NC-SA 4.0. See LICENSE for details.