Full Stack Web Application

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.

3,000+ games ingested~90% less manual onboardingRBAC + 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
Interface

Arcade Neon Dashboard

Trending & New Collections
Discovery

Trending & New Collections

Dynamic Category Navigation
Discovery

Dynamic Category Navigation

Immersive Gameplay & Community
Gameplay

Immersive Gameplay & Community

Real-Time Analytics Dashboard
Admin

Real-Time Analytics Dashboard

Mass Import Configuration
ETL Pipeline

Mass Import Configuration

ETL Pipeline in Action
ETL Pipeline

ETL Pipeline in Action

Game Availability Manager
Content Management

Game Availability Manager

Ad Placement Strategy
Monetization

Ad Placement Strategy

Dynamic Script Injection
Monetization

Dynamic Script Injection

Dynamic Favicon Management
SEO & Branding

Dynamic Favicon Management

Global SEO Configuration
SEO & Branding

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

games.selectgames.insertgames.updategames.deletecategories.selectcategories.insertcategories.updatecategories.deletetags.selecttags.inserttags.updatetags.deleteproviders.selectproviders.insertproviders.updateproviders.deleteusers.selectusers.updateusers.delete

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