ONEUX
One Next.js application serving five of a multinational company's brands: a social feed, push notifications, missions, events and rewards, inside a portal it does not own.
Scale / counted from the repository
- brands served by one codebase
- 5
- routes rendered from 7 page files
- 23
- lines of frontend in the repository
- 121,227
- component domains in the repository
- 68
- test files in the repository
- 223
- files carrying my commits
- 251
- commits I authored, reachable from HEAD
- 210
- image assets behind the brand themes
- 923
- Role
- Senior Web Developer
- Period
- April — September 2026
- Type
- Multi-brand engagement platform
- Stack
- Next.js App Router, React, JavaScript, Tailwind CSS, React Query, PWA & web push
01
Overview
A user of this platform earns points, checks in daily, works through missions, reserves a seat at an event, submits work for a competition, follows a social feed and redeems vouchers: one login, one app, five brands. The brands share a voice but not a colour, a corner radius or an asset set, and the whole thing runs inside a frame owned by the company's parent portal. Three constraints shaped every frontend decision in it, and none of the three were mine to design.
Where a session starts, in order
- 01Portal hand-off
- 02Age gate
- 03Login
- 04Brand preference
- 05Feed
02
The challenge
Pages were data. Most routes did not exist as files: a catch-all route read a schema from the API and assembled the page from it, so one component could render a different layout per brand without a deploy, and seven page files served 23 routes. Getting a new section to look right meant getting it right for a schema I did not write. The app also lived inside someone else's window. It ran in a cross-origin frame owned by the parent portal, where cookies, storage, service workers and device permissions behave differently and some are simply unavailable, so anything that assumed them broke. And the category was regulated: age and brand preference had to be confirmed before a user could interact, and the separation between brands had to hold under every feature. Compliance was a constraint on the architecture, not a page at the end of a funnel.
03
My contribution
- Built the notification layer a user meets: the banner that asks for permission, the toggle in settings, the notification list page, and the state deciding which of those appears.
- Held the feed as primary maintainer — posts, stories, comments, likes, audio posts, filtering, and the onboarding tour wrapped around them — a module another engineer started.
- Owned the event detail header: the distance from a user to an event, the permission states the browser reports, and the gate that once kept the feature to installed users.
- Wrote the analytics contract for the feed, so a name and a payload are defined once instead of being pushed at every call site.
- Carried the per-brand pass that followed every change: one component, five palettes, five sets of assets, no forks.
04
Inside the product
01
A page is a schema, not a file
src/app/(app)/[[...page]]/page.js · ui_schema
Most routes were not files. One catch-all route read a schema from the API and assembled the page from it, which is how seven page files served 23 routes and how a brand could change a layout without a deploy. Styling arrived the same way: Tailwind plus a block of CSS variables injected from the API, so the same class meant a different colour on each brand. A section was finished when it was right for a schema I did not write.
02
The gate comes before the feed
brand-pref-popup.jsx · community-hub-guard.jsx · login-popup.jsx
Age and brand preference had to be confirmed before a user could interact, so the gate sits in the onboarding order rather than at the end of a funnel. The preference popup calls its completion callback only when the save succeeds, which means nobody slips past consent without recording a choice. Around it the guard lets a logged-out visitor read the feed instead of blocking them, and traffic arriving from the portal re-asks the age gate rather than waving a returning user through.
03
Nothing assumes a browser it does not own
use-push-notif.js · notif-provider.jsx · TopNotifBanner.jsx
A user would install the app, grant notifications, leave the tab until the session expired, and come back to be told they were not subscribed while the device said otherwise. It never reproduced on desktop: on iOS web push exists only for an installed app, so the bug lived where most of the team could not look. The cause was load order — initialisation ran before the login check resolved, read a false value and wrote it down — plus a second copy of the subscription state, so the screen and the truth could drift apart. The fix was an effect that rebuilt state once login resolved, and one source of truth instead of two. The banner then lost its local state and the popup tucked inside it, so it could be mounted anywhere in the layout.
04
One feed, five brands, no forks
community-hub/** · BRAND_THEMES · BRAND_SOURCE_MAP
The cheap route is a copy of the component per brand, or a branch on the brand name inside the markup. Both work for a week; after that every small change is five changes and the five quietly stop behaving the same. So the per-brand values became data — colour themes, the skip button on the tour, the source label, the notification category colours — read from maps instead of conditionals, and the feed stayed one component on one code path. It sat behind 923 image assets, and the list alone took 22 commits of adjustment that never touched structure.
05
Distance behind a state machine
detail-header.jsx · info-header-track.jsx
The event header tells a user how far they are from the venue: haversine, metres under a kilometre and kilometres above it. The device decides the rest, and it reports more than granted or denied — idle, loading, granted, denied, unavailable — so each state gets its own sentence, because a user who refused permission and a user whose device cannot answer need different words. The feature began behind a check for installed users; once the handling was good enough for an ordinary browser, the check came off.
06
An analytics contract other teams consume
use-datalayer.js · community-hub/**
Events leave through one hook rather than a push at every call site, so a name and its payload are defined once: post and story interactions, suggested profiles, the interest popup, navigation, and the call to action on a challenge. Renaming an event a dashboard already reads is the risky half, and that happens in the same change as the consumer update, never as a quiet rename that breaks someone else's report a week later.
05
Technical approach
Brand differences are data
A value that changes per brand goes into a map, never an `if (brand === ...)` scattered through markup. One lookup replaces five conditionals, and the day a sixth brand arrives it is an entry rather than an audit.
BRAND_THEMES · BRAND_SOURCE_MAP · SKIP_BUTTON_STYLES
Logic out of the view
One hook per resource, with React Query holding the cache and the optimistic update for a like or a comment; pure helpers turning an API response into a view model, so the mapping is testable without a render; components that draw what they are handed. The permission banner ended as the clearest case: no local state, no popup inside it, mountable anywhere because it no longer decides anything.
one hook per resource · *-utils.js · display-only components
Ask the browser first
On a platform like this the expensive discoveries are environmental. Whether push exists, whether storage survives, whether a permission prompt can be raised at all: inside another origin's frame, several of those answers are no. Finding that out early turns it into architecture, which is cheap. Finding it out in a ticket eight weeks later is not.
framed origin · installed-only web push · device permissions
Built with
- Framework
- Next.js, App RouterReactJavaScript
- Interface
- Tailwind CSSSCSSBrand CSS injected from the API
- Data and state
- React QueryREST API layerServer-rendered catch-all routes
- Platform
- Service worker via next-pwaFirebase Cloud MessagingInstall prompt
- Test and quality
- VitestTesting LibrarySnapshotsSonarQube coverage
06
Where it stands
01
The foundation is not mine
The service worker and the FCM token manager under the notification layer were another engineer's, and so was the permission module. What I owned above them was the ordering bug and the state model, which is why the recovery case has a test.
02
Neither are the first versions
The feed and the profile revamp were both started by other engineers. I maintained and extended them, including the switch that lets one brand run the new profile page while the rest fall back to the old one. That line stays visible on purpose: a page that claims everything reads like a page that checked nothing.
03
No screenshots
The product is private and runs inside a portal I do not own, so the cover and any media slot stay schematic rather than pretending to be captures of something.
04
No repository to link
Where other entries here link public code, this one cannot. The numbers are counted from a private repository, the brands are described rather than named, and no endpoint or customer figure appears on this page.
05
Test coverage is uneven
The modules most likely to regress carry tests: the banner in each of its states, the recovery race, the notification page, the brand preference popup, and the event header with a snapshot. Everything else I touched does not, because deadlines are real and the suite was written where a mistake would be expensive.
07
Outcome
Five brands run one component layer and one code path, so a change made for one arrives for the others instead of being re-made four times. The parts I held shipped on all five. What the brands gained commercially belongs to the brand owner and stays unpublished; everything numbered above is counted from the repository.