.catch(() => null) unioned the query result with null, so destructuring the first row failed typecheck (TS2488). Return an empty array on failure instead and drop unused connection/desc imports.
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 | >= 22 | Required by Next.js 16 |
| pnpm | >= 10.33.4 | Package manager (npm/yarn not supported) |
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
| Redis | 7.x+ | Optional — caching, rate limiting, SSE |
| Java | 17+ | Required only if building the emulator |
| Maven | 3.9+ | Required only if building the emulator |
Quick Start
1. Clone & Install
git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git
cd EpicNext-Cms
pnpm 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/(pnpm db:migrate).
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:3000
See .env.example for all optional variables (RCON, email, Redis, OAuth, PayPal, etc.).
4. 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 into drizzle/migrations/) |
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 names + 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.
Use import { db } from "@/lib/db" with table definitions from src/db/schema.ts for all database access. Types come from the committed Drizzle schema — no separate client code generation is required at build time.
5. Run CMS Migrations
pnpm 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.
Check migration status:
pnpm db:migrate:status
6. Build & Start
# Development (hot reload)
pnpm dev
# Production
pnpm build && pnpm start
Open http://localhost:3000 in your browser.
7. 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.
Nginx Configuration
The CMS is designed to run behind an nginx reverse proxy. Below is a reference configuration covering SSL termination, WebSocket upgrade, proxy caching, and the Habbo imager integration.
Prerequisites
- SSL certificates in
/etc/ssl/cert.pemand/etc/ssl/key.pem(or use Let's Encrypt) - Next.js running on
127.0.0.1:3000(default) or your configured port - Habbo imager (optional) running on
127.0.0.1:3030
Reference Configuration
Create a file in /etc/nginx/sites-available/epicnext and symlink it to sites-enabled:
# ==========================================
# GLOBAL SETTINGS
# ==========================================
server_tokens off;
gzip on;
gzip_vary on;
gzip_proxied off;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types text/plain text/css text/javascript application/json
application/javascript application/xml application/xml+rss
image/svg+xml font/opentype font/ttf font/woff font/woff2;
# ==========================================
# REDIRECT HTTP → HTTPS
# ==========================================
server {
listen 80;
listen [::]:80;
server_name yourdomain.com www.yourdomain.com;
location /.well-known/acme-challenge/ {
root /var/www/epicnext/public;
}
location / {
return 301 https://$host$request_uri;
}
}
# ==========================================
# MAIN HTTPS SERVER
# ==========================================
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name yourdomain.com www.yourdomain.com;
root /var/www/epicnext/public;
index index.html;
# SSL Certificates
ssl_certificate /etc/ssl/cert.pem;
ssl_certificate_key /etc/ssl/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# Security Headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
client_max_body_size 20m;
client_body_timeout 30s;
client_header_timeout 10s;
keepalive_timeout 15s;
send_timeout 10s;
# Shared Proxy Settings
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;
proxy_buffers 16 16k;
proxy_buffer_size 32k;
# ------------------------------------------
# Static Files
# ------------------------------------------
location ^~ /nitro-client/ {
alias /var/www/Nitro-V3/dist/;
expires 7d;
add_header Cache-Control "public";
access_log off;
}
location = /favicon.ico { expires 1y; access_log off; log_not_found off; try_files $uri =404; }
location = /robots.txt { expires 1d; access_log off; log_not_found off; try_files $uri =404; }
# ------------------------------------------
# Next.js Assets (immutable, long cache)
# ------------------------------------------
location /_next/static/ {
proxy_pass http://127.0.0.1:3000;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location /_next/data/ {
proxy_pass http://127.0.0.1:3000;
add_header Cache-Control "public, max-age=0, must-revalidate";
}
# ------------------------------------------
# API Routes (never cached)
# ------------------------------------------
location /api/ {
proxy_pass http://127.0.0.1:3000;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# ------------------------------------------
# Habbo Imager (optional)
# ------------------------------------------
# Proxies to a Docker container that renders Habbo avatars.
# The imager caches renders to disk, so a long s-maxage is safe.
location /imaging {
proxy_pass http://127.0.0.1:3030;
add_header Cache-Control "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400" always;
}
# ------------------------------------------
# WebSocket (Radio / SSE)
# ------------------------------------------
location /ws {
proxy_pass http://127.0.0.1:3030;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
}
# ------------------------------------------
# Main Page Proxy (with HTML caching)
# ------------------------------------------
# The CMS middleware sets:
# Cache-Control: public, s-maxage=300, stale-while-revalidate=300 (anonymous)
# Cache-Control: private, no-store (authenticated)
#
# nginx caches anonymous responses and serves them directly, bypassing
# the Node.js process entirely. Authenticated responses are never cached.
#
# proxy_cache_valid: cache 200 responses for 60 seconds
# proxy_ignore_headers Vary: Next.js emits many Vary headers (rsc,
# next-router-*, Accept-Encoding) that would fragment the cache key.
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
proxy_http_version 1.1;
proxy_buffering on;
proxy_cache html_cache;
proxy_cache_valid 200 60s;
proxy_cache_key "$host$request_uri";
proxy_ignore_headers Vary;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_revalidate on;
add_header X-Cache-Status $upstream_cache_status always;
}
# ------------------------------------------
# Health Check
# ------------------------------------------
location /health {
access_log off;
return 200 "OK";
add_header Content-Type text/plain;
}
# Block hidden files
location ~ /(\.|vendor|storage/logs/|\.(sql|sqlite|sqlite3)$) {
deny all;
access_log off;
log_not_found off;
}
}
HTML Caching
The CMS uses an origin-level proxy cache for anonymous HTML pages. This means:
- Anonymous visitors receive cached HTML directly from nginx (~1ms), skipping the Node.js process entirely.
- Authenticated visitors always hit Node.js (personalized content).
- The cache is auto-invalidated after 60 seconds and revalidates in the background.
The proxy cache zone is defined in the http block (above any server block):
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;
Verify caching works by checking the X-Cache-Status response header:
# First request (MISS = fetched from Node.js, now cached)
curl -sI https://yourdomain.com/ | grep X-Cache-Status
# → X-Cache-Status: MISS
# Second request (HIT = served from nginx cache)
curl -sI https://yourdomain.com/ | grep X-Cache-Status
# → X-Cache-Status: HIT
Key Points
| Setting | Value | Why |
|---|---|---|
proxy_http_version 1.1 |
HTTP/1.1 to upstream | Required for keep-alive and chunked transfer |
proxy_buffering on |
Buffer upstream response | Required for proxy_cache to work with chunked responses |
proxy_ignore_headers Vary |
Ignore upstream Vary | Next.js emits dynamic Vary headers (rsc, next-router-*) that would fragment the cache |
proxy_cache_valid 200 60s |
Cache 200s for 60s | Balances freshness with performance |
proxy_cache_use_stale |
Serve stale on error | Keeps the site available during brief upstream outages |
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
The CMS runs behind an nginx reverse proxy on the default port 3000. Static assets (media uploads) are persisted via /api/media/* and survive rebuilds.
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 analyze |
Build + open bundle analyzer |
pnpm jobs:worker |
Start background task worker (systemd / PM2) |
pnpm biome:check |
Lint and format code |
Drizzle Kit notes: db:generate / db:introspect write drafts only. Reviewed SQL must be copied into drizzle/migrations/ as a new numbered file, then applied with pnpm db:migrate. Never run drizzle-kit push or drizzle-kit migrate against production.
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 (respects prefers-reduced-motion) |
| Server-Sent Events | Real-time radio now-playing & listeners via SSE (no polling) |
| Redis Caching | Caches API responses (home, radio config) up to 30s in Redis |
| Bundle Analyzer | Run pnpm analyze to visualize and optimize bundle sizes |
| 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 |
| CSS-based Animations | input-glow, btn-shine, Reveal scroll animations, floating orbs |
| Compression | Gzip compression enabled on all responses |
| Stale Times | Optimized router cache (30s dynamic, 180s static) |
| 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
│ ├── merge-config.cjs # Utility: merge split config files
│ └── generate-drizzle-schema.mjs # Regen src/db/schema.ts from schema + live DB
├── 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)
│ │ ├── cached-db.ts # Redis-backed query cache helpers
│ │ ├── redis.ts # Redis client (ioredis)
│ │ ├── redis-cache.ts # Redis caching utility for API routes
│ │ ├── cache.ts # In-memory cache fallback
│ │ ├── motion.ts # Framer Motion animation variants
│ │ └── use-event-source.ts # React hook for SSE subscriptions
│ ├── messages/ # i18n translations (en, nl, de, fr, es, it, pt, da, no, sv)
│ └── env.ts # Zod-validated environment schema
├── public/
│ ├── assets/ # Images, icons, fonts
│ └── scripts/ # Client-side scripts (theme-init.js)
├── update-Nitrov3.sh # Emulator & Nitro updater utility
├── next.config.ts # Next.js configuration
└── .env.example # Environment template with all options
Database Ownership
| Component | Type | Migrations |
|---|---|---|
Emulator tables (users, items, rooms, etc.) |
Existing Polaris schema | None — CMS reads/writes only |
CMS tables (website_*, radio_*, etc.) |
CMS-owned | drizzle/migrations/*.sql |
| Migration tracking | cms_migrations table |
Auto-created by migration runner |
ORM Architecture
- Runtime (Drizzle ORM):
@/lib/dbexposes a Drizzle singleton. Schema lives insrc/db/schema.ts. - Schema regeneration:
pnpm db:schema:generatereuses field/table names from the previoussrc/db/schema.tsand refreshes column types from the live DB. - Drizzle Kit: studio / generate / introspect for local tooling; CMS apply path remains
pnpm db:migrate.
Use import { db } from "@/lib/db" with queries built via src/db/schema.ts.
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
Scheduled tasks:
- Daily 03:00 — Emulator JAR backup (requires
EMULATOR_JAR_PATH+EMULATOR_BACKUP_DIR) - Daily 04:00 — Cleanup login logs (>30 days) and expired password reset tokens (>7 days)
CAPTCHA
Supports Cloudflare Turnstile and Google reCAPTCHA. Configure via CMS Settings.
Theming
12 preset themes with fully customizable colors via Admin → Theme (/admin/theme).
AI Content Moderation
Optional OpenAI-powered moderation for comments and guestbook posts. Set OPENAI_API_KEY.
FlareSolverr (Cloudflare Bypass)
Some clone sources (e.g. Leet, Hubbly, Habblet City) are protected by Cloudflare and return challenge pages instead of JSON. FlareSolverr acts as a proxy that solves these challenges automatically.
Setup:
docker compose up -d flaresolverr
Set the URL in .env:
FLARESOLVERR_URL=http://localhost:8191
When a source URL returns a 403 or HTML (CF challenge), the clone import automatically falls back to FlareSolverr. If FlareSolverr is not running or is unreachable, the source is skipped with a warning.
See docker-compose.yml for the FlareSolverr service definition.
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, minimal client boundaries)
- Use the
src/lib/motion.tsanimation variants for consistent animations - SQL migrations in
drizzle/migrations/must be idempotent - For new database code, use the Drizzle runtime directly (
import { db } from "@/lib/db") — see ORM Setup - Avoid
any— useeslint-disableorbiome-ignorecomments only when unavoidable
License
CC BY-NC-SA 4.0. See LICENSE for details.