Game-Web:
Enterprise Gaming
A production browser gaming portal on Next.js 15 and Supabase. An automated ETL pipeline bulk-ingests and normalises 3,000+ external games, cutting manual onboarding by roughly 90%, behind an RBAC admin CMS with row-level security.
TECHNICAL HIGHLIGHTS
Hybrid Rendering
SSR for SEO pages, CSR for dashboards
RBAC Security
Middleware-protected User/Admin separation
ETL Pipeline
Ingested 3,000+ games from external APIs
Real-time Analytics
ApexCharts with SQL aggregation
Dynamic SEO
Open Graph + Twitter Cards from DB
Monetization Engine
Ad placement + script injection system
Visual Tour
Explore the platform's features through detailed screenshots.

Arcade Neon Dashboard

Trending & New Collections

Dynamic Category Navigation

Immersive Gameplay & Community
Real-Time Analytics Dashboard
Mass Import Configuration
ETL Pipeline in Action
Game Availability Manager
Ad Placement Strategy
Dynamic Script Injection
Dynamic Favicon Management
Global SEO Configuration
Ingestion
Two catalogues, one schema
Onboarding a browser game by hand means copying a title, description, thumbnail, play URL, category list and tag list out of a provider's feed and into a form. Done once it is tedious; done three thousand times it is not a job anyone does. The pipeline exists so nobody has to, and most of its design is about surviving the second and third run rather than the first.
FOUR DECISIONS
Two feeds that disagree on shape
GamePix returns categories as an array. GameMonetize returns a single comma-separated string. translateGameData branches on which one it got, splits and trims the string form, folds a stray top-level category back into the list, and emits one internal shape either way.
Re-importing is not destructive
The batch write is an upsert with onConflict: 'play_url'. Running the same import twice updates the existing rows instead of duplicating the catalogue, so a partially-failed run can simply be run again.
Normalisation is enforced in the database
A Postgres trigger, enforce_lowercase_categories_tags, lowercases every element of the categories and tags arrays on write. Two feeds spelling the same tag differently collapse at the table rather than in whichever code path happened to insert them.
Providers are resolved, not hardcoded
getOrCreateProviderId looks a provider up by URL and creates it if it is missing, behind a five-minute in-process cache. A new feed is a config entry, not a migration.
WHAT THAT BOUGHT
The admin import screen ends up doing very little. It picks a provider, optionally picks a category, pages through the feed ten rows at a time, and holds the operator's selection in localStorage so a half-finished review survives a refresh. Pressing save hands the selected raw records to a single server action and everything downstream — translation, provider resolution, normalisation, conflict handling — happens without the UI knowing about it.
The measured effect is on manual effort rather than on traffic: bulk ingestion removed roughly ninety per cent of the per-title data entry that adding a game used to require. That is the only number on this page, and it is a number about the workflow, not about the audience.
Removal is a flag, not a delete. Games carry an is_active boolean and every public read filters on it, so hiding a title from the site leaves its play history and comments intact for later.
Authorisation
The rules live in Postgres
The admin CMS and the public site are the same Next.js application talking to the same database. If access control lived in the application layer, every new route would be a fresh chance to forget a check. So it does not live there.
HOW A CHECK RESOLVES
Roles are a Postgres enum. Permissions are a second enum, and a role_permissions table joins the two. A SECURITY DEFINER function called authorize() answers whether the current caller holds a given permission, and row-level security policies on every table call it.
The part worth stealing is how the role gets there. A Supabase auth hook, custom_access_token_hook, reads the user's role once at token-mint time and stamps it into the JWT as a claim. authorize() then reads auth.jwt() instead of querying the roles table on every policy evaluation — so the permission check costs one small lookup against a join table rather than two.
The practical consequence is that an anonymous visitor and a signed-in admin can hit the same query and get different rows back, without the route handler containing a single conditional about it.
19 PERMISSIONS
The app_permission enum, verbatim. Storage buckets carry their own policies on the same role.
Monetisation
Third-party tags, without editing the app
Ad units and analytics tags change more often than the code around them does, and each change is normally a deploy. Here an admin pastes a raw snippet and names a target element; the server parses that element description into a tag name and an attribute map with a regular expression — deliberately not JSDOM, which would mean shipping a DOM implementation into a server action for one small job.
On the client, that attribute map is rebuilt into a CSS selector, the target is located, and the snippet is injected — <ins> units into the matched element, head-level scripts, meta and link tags into document.head. An existing ins.adsbygoogle is removed before a new one is appended, so a re-render cannot stack duplicate slots.
THE TRADE-OFF
Injection happens after mount rather than in the server-rendered markup. That is the deliberate half of the design: vendor tags never enter the initial HTML, so a slow or failing ad network cannot hold up first paint, and the slot is created at a known point in an existing element rather than shifting the layout underneath content that has already been painted.
The honest cost is that it is a string-and-selector contract. If an admin names a target that no longer exists in the markup, the snippet silently does nothing — the failure is logged to the console and nowhere else. A registry of named, code-declared slots would be sturdier than free-text selectors; free-text selectors are what let a non-developer place a unit anywhere without waiting for a release.
The same request-time philosophy runs through the metadata. Site name and description are read from the database inside generateMetadata, and each game page composes its own title, description, Open Graph image and Twitter card from the ingested record — so a title that arrived through the ETL pipeline is fully indexable without anyone touching a template.
TECHNOLOGY STACK
frontend
Next.js 15
App Router, Server/Client Components
React 18
Hooks, Concurrent Features
TypeScript
Type-safe development
Tailwind CSS
Utility-first styling
Radix UI
Accessible component primitives
shadcn/ui
Beautiful component library
Zustand
Lightweight global state
backend
Supabase
PostgreSQL + Auth + Storage
Row-Level Security
Strict data isolation
Server Actions
Type-safe API endpoints
Zod
Runtime schema validation
devops
Vercel
CI/CD deployment
Edge Functions
Global CDN caching