Getting the first version of Aluxbound to work was only one part of the project.
Once the main flows were in place, the smaller problems started appearing. The hero video did not behave the same way on every browser. The images were not loading as reliably as I wanted. I needed notifications for the admin panel, and a public contact form cannot be left open to unlimited requests.
None of these problems needed a completely new architecture. They needed a few decisions that were easy to miss while building the main product.
What Aluxbound is
Aluxbound is a luxury travel website where visitors can explore destinations and services, then send an enquiry for a trip.
The other half of the project is the admin panel. It lets the business manage destinations, review enquiries, create custom plans, and send payment links without editing the code or touching the database directly.
The project is built as a Next.js app inside a Turborepo monorepo. tRPC handles the API layer, Prisma connects it to PostgreSQL on Neon, and the shared packages contain authentication, environment validation, database access, and UI components.
The public website is the part most people see. The admin panel and the systems behind it are where most of the interesting engineering decisions happened.
The hero video and iOS Low Power Mode
The hero section has a video playing in the background. It works well on most browsers, but Safari and iOS browsers are different because they use Apple's WebKit engine.
In Low Power Mode, autoplay can be blocked. The video element can even emit a playing event while the video is still not actually moving. That created a bad experience: the page could reveal a paused video with a native pause icon instead of showing a clean background.
The first version assumed that a playing event meant the video was playing. That assumption was wrong.
I changed the logic so the video stays hidden until playback is confirmed. When the browser says that the video is playing, I record its current time and check again shortly after:
1const timeAtEvent = video.currentTime2 3playbackCheckTimer = setTimeout(() => {4 const isActuallyPlaying =5 !video.paused &&6 video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA &&7 video.currentTime > timeAtEvent8 9 if (!isActuallyPlaying) {10 showFallback()11 return12 }13 14 setHeroVideoState("playing")15}, 150)Until that check succeeds, the video has opacity-0 and the hero uses a preview image instead. If play() is rejected, the video has an error, or the fallback timer expires, the image stays visible.
This also avoids showing a native pause icon over the hero. The user either sees a working video or a proper image. They do not see the browser struggling with autoplay.
Detecting the browser before choosing the fallback
I used the bowser package to inspect the user agent. The important part was not just detecting Safari. On iOS, Chrome and Firefox also use WebKit, so they need the same fallback behaviour.
On macOS, I only wanted to treat Safari as an Apple browser. Chrome and Firefox should keep using the normal video path.
1import Bowser from "bowser"2 3export function isAppleSafariUserAgent(userAgent: string) {4 const browser = Bowser.getParser(userAgent)5 const os = browser.getOSName()6 const name = browser.getBrowserName()7 8 return os === "iOS" || (os === "macOS" && name === "Safari")9}I also cache the result in a cookie so the server can know the browser type early and pass it into the hero component. The client still checks window.navigator.userAgent, because that is the final source of truth once the page is running.
Four videos became one WebM
The original hero used four separate MP4 videos. Together, they took around 55 MB.
That was too much for a background video. It would make the first load heavier, and most users would not even watch all four clips in a single visit.
I edited the clips into one shorter video where each part plays for around 2.5 seconds. Then I converted it from MP4 to WebM. The final storage went from roughly 55 MB to around 5 MB, with little to no visible difference in the hero.
The code still keeps separate desktop and mobile video sources because the aspect ratio and framing are different. The important change was reducing the media before sending it to the browser, not trying to solve the whole problem with code.
Browser notifications for the admin panel
The admin panel needs to know when a new enquiry arrives. Email is useful, but it is not always the fastest way to notice something while working inside the dashboard.
I added browser push notifications using VAPID and web-push.
The browser asks for permission, registers the service worker, creates a push subscription, and sends the endpoint and keys to the server. The server stores each subscription against the logged-in admin user.
The endpoint is unique, so the subscription is upserted rather than inserted every time:
1return db.browserPushSubscription.upsert({2 where: { endpoint: input.endpoint },3 create: {4 userId: ctx.session.user.id,5 endpoint: input.endpoint,6 p256dh: input.keys.p256dh,7 auth: input.keys.auth,8 },9 update: {10 userId: ctx.session.user.id,11 p256dh: input.keys.p256dh,12 auth: input.keys.auth,13 },14})When an enquiry is created, the server finds the saved subscriptions and sends the notification to each device. This matters because the admin may be logged in from a laptop and a phone at the same time.
Notifications also have their own read state. The notification belongs to the enquiry, while NotificationRead belongs to a particular user. That way, one admin reading a notification does not mark it as read for every other admin.
Why I moved image uploads to Cloudinary
At first, I was using image URLs directly. That was quick, but it also meant that a bad URL could result in a failed image request or a slow preview.
Storing the images in the database would not have solved the UI problem either. The admin would still need a simple way to select an image, preview it, and know that it was saved correctly.
I added Cloudinary uploads to the destination form. The admin chooses a file, the form shows a local preview, and the upload happens when the destination is saved.
The signing request stays on the server. The server checks that the user is an admin, rate limits the signing endpoint, and returns a short-lived signature. The browser then uploads the file directly to Cloudinary and stores the returned secure_url with the destination.
1const uploadData = new FormData()2uploadData.append("file", file)3uploadData.append("api_key", apiKey)4uploadData.append("timestamp", timestamp)5uploadData.append("folder", "aluxbound/destinations")6uploadData.append("signature", signature)7 8const response = await fetch(9 `https://api.cloudinary.com/v1_1/${cloudName}/${resourceType}/upload`,10 { method: "POST", body: uploadData }11)Cloudinary also gave the project useful free limits while I was still testing the product. I did not have to build media storage and image delivery before knowing whether the workflow was useful.
Choosing services with reasonable starting limits
I was trying to keep the initial cost low because this started as a test project.
Neon gave me a serverless PostgreSQL database. Resend handled transactional email for the custom plans and enquiry flow. PostHog gave me a way to see events such as an enquiry being submitted, a destination being created, and a payment link being opened.
The point was not to choose a service because it was free forever. It was to avoid paying for infrastructure before the product had enough usage to justify it.
Rate limiting the public endpoints
The signup and contact flows are public. That means someone can send repeated requests even if they never become a real user or submit a real enquiry.
I added rate limiting backed by PostgreSQL. The limiter uses a fixed window and an atomic database update, so the limit is shared across deployments instead of living inside one server process.
For authentication, I rate limit by IP. Sign-in also has an email-based limit, which stops someone from repeatedly targeting one account from different addresses. The tRPC handler also has general and mutation limits, which covers public mutations such as the contact form.
The core helper stays small:
1export async function consumeRateLimit(2 key: string,3 limit: number,4 windowMs: number5) {6 const resetAt = new Date(Math.floor(Date.now() / windowMs + 1) * windowMs)7 8 // Insert the bucket or increment it atomically in PostgreSQL.9 // The response includes whether the request is still allowed.10}This is not a replacement for network-level DDoS protection. It is a simple application-level boundary that stops the obvious repeated requests from reaching the business logic without a limit.
Mobile first meant sharing the mobile decision
The website was mobile first, so I needed the same breakpoint logic in more than one component. Instead of checking window.innerWidth in every place, I created a small use-mobile.ts hook around matchMedia.
1const MOBILE_BREAKPOINT = 7682 3export function useIsMobile() {4 const [isMobile, setIsMobile] = React.useState<boolean | undefined>()5 6 React.useEffect(() => {7 const mediaQuery = window.matchMedia(8 `(max-width: ${MOBILE_BREAKPOINT - 1}px)`9 )10 const onChange = () => setIsMobile(mediaQuery.matches)11 12 mediaQuery.addEventListener("change", onChange)13 setIsMobile(mediaQuery.matches)14 15 return () => mediaQuery.removeEventListener("change", onChange)16 }, [])17 18 return !!isMobile19}It is a small hook, but it keeps the mobile behaviour consistent across the admin panel and the public website. The hero video, navigation, forms, and sidebar can all respond to the same breakpoint.
The deployment changed with the project
I did not want to spend much at the beginning. Vercel's free plan was not suitable for the commercial direction I had in mind, and the other option I considered was Cloudflare. The edge function support I needed was not a good fit for that first setup.
So I started with Netlify. At that point, the deployment only needed a netlify.toml file.
Later, as the website started to take off, I moved to Vercel Pro. That was a better decision once the project had enough reason to justify the cost.
The code did not change because of one big rewrite. It changed through small problems: a video that looked paused, an image that failed to load, a notification that needed to reach the right device, and a public endpoint that needed a limit.
That is usually what the final version of a project hides. The website looks like one product, but it is made up of many small decisions that only become obvious after someone starts using it.
