How a trip to Egypt gets booked.
A multilingual tour-booking platform: a React front end, an ASP.NET Core API, SQL Server, and hosted card payments. Here is what happens behind each click.
- UI languages
- 11
- UI languages
- step booking wizard
- 7
- step booking wizard
- API routes
- 170+
- API routes
- entity sets
- 40
- entity sets
- EF Core migrations
- 36
- EF Core migrations
01A visitor opens a tour package page
A React app in eleven languages
React 18, built with Vite. Tailwind holds the site's colour tokens (desert gold, Nile blue, sunset orange). Routing is React Router v6 and API data runs through TanStack Query. Public pages are prerendered as HTML for search engines, then hydrated in the browser.
- /
- /package-catalog
- /package-details/:id
- /multi-step-booking/:id
- /day-trips/:slug/book
- /trip-cost-estimator
- /dashboarduser
- /admin-package-managementadmin
Pages load only when they're needed
Every page is a React.lazy chunk behind a Suspense fallback. Pages that need an account are wrapped in ProtectedRoute: the dashboard, profile and inquiry tracking need a signed-in user, and the 20+ admin screens need the admin role. Old URLs like /packages redirect to their canonical page.
TanStack Query holds the API data
One QueryClient (60-second stale time, one retry). Every content query key includes the language, so English and Arabic results never mix in the cache. A shared axios instance sends cookies on every call. A server error (5xx) shows a toast. A 401 clears the cached user and returns them to sign-in.
Prerendered, then hydrated
At build time a script fetches every package. Puppeteer then renders each public route to static HTML and embeds the package data in the page. In the browser that data fills the query cache before hydrateRoot runs, so the first paint is real content and the page doesn't refetch.
Eleven languages, one in the bundle
English ships in the JavaScript bundle. The other ten (es, pt, tr, ar, fr, de, it, nl, ru, pl) load from the API, which fills gaps with DeepL and caches the results in SQL Server. Hand-written SEO headings for nine of the languages are locked, so machine translation can't overwrite them. Choosing Arabic sets dir="rtl" on <html> and the layout mirrors.
ASP.NET Core 10, layered and locked down
A .NET 10 minimal-API backend split into Domain, Application and Infrastructure projects, with EF Core and SQL Server underneath. The routes are documented in a hand-written reference in the repo.
Four projects, dependencies point inward
Endpoints send commands and queries through MediatR. FluentValidation runs as a pipeline step before any handler. Handlers depend only on interfaces. Infrastructure implements them with EF Core, MailKit, DeepL, Google sign-in, GetPayIn and Azure OpenAI (package Q&A). The domain project references no framework at all.
Every request takes the same path
X-Forwarded-For is trusted only from known proxy IPs. In production the API won't start without that list, so nobody can fake their IP to dodge the rate limits. Next come CORS, JWT authentication, role checks, a CSRF guard, output caching and the rate limiter. Any exception comes back as the same JSON error shape.
A request budget for each route
Eight fixed-window rate limits run per IP, from 600 requests a minute overall down to 5 for login and the contact form. Translation routes get their own limit, because every cache miss spends DeepL credits. The request body size is capped low everywhere so ordinary routes can't be used to exhaust memory. Upload routes raise their own cap.
Sign-in without exposing tokens to JavaScript
The browser calls the API through a /api proxy on the site's own domain, so the auth cookie is first-party. That's what stops iOS Safari from signing people out.
Sign in
Login is limited to 5 requests a minute per IP. A MediatR LoginCommand is validated, the user is loaded through EF Core, and the password is checked against its BCrypt hash.
The token goes in an httpOnly cookie
The API signs an HS256 JWT that lasts 12 hours. Issuer, audience and expiry are all checked, with zero clock skew allowed. It's sent as an httpOnly, Secure cookie, so JavaScript can't read it. The app caches only display fields (name, email) in a separate cookie. The role is never cached; it always comes from the server.
Every request after that
The JWT middleware reads the token from the cookie. Any cookie-authenticated request that changes data must also carry X-Requested-With, which a cross-site form can't add. On load the app asks the API for the profile; the role always comes from the server.
There is no refresh token. When the 12 hours run out, the next call returns 401: the app clears the cached user, signals the session ended, and sends the user to sign in again.
Or sign in with Google
Google Identity Services gives the browser an ID token. The API checks it with Google's own library and then sets the same httpOnly cookie, so everything after sign-in works the same way.
From “this trip” to an inquiry on the agent's desk
A booking starts as an inquiry: the traveller builds the trip and the agency confirms it. The browser runs the wizard, the server checks the price again, and the agency gets an email.
- Package Selection1
- Traveler Details2
- Date Selection3
- Room Configuration4
- Hotels & Accommodation5
- Supplements6
- Review & Confirm7
A seven-step wizard
Package, travellers, dates, rooms, hotels & cruises, supplements, review. The whole trip lives in one useReducer, so going back a step never loses anything. Day trips have their own six-step flow.
Priced live in the browser
Single, double and triple rooms are priced per person. Seasonal adjustments, child and orphan discounts, private-service supplements, domestic flights and tax are applied as the traveller clicks, so the total is always visible.
The server re-prices it
Sending the inquiry is a single request. The API recalculates the total from the package data and rejects it if the numbers don't match. It then saves the booking, emails the agency through MailKit, and returns a guest token signed with an HMAC and valid for 14 days. Guests can pay without an account, and nobody can reach a booking by guessing its id.
Then pay now, or wait
The confirmation page offers a deposit or full payment straight away. Signed-in travellers can also follow their inquiry from the dashboard while the agency confirms it.
The estimator is four steps of arithmetic
It's a planning tool, not a quote, and it runs entirely in the browser: no API call, no account. The calculator below uses the same constants as the live page.
- 1Pick a style
Daily rates for hotel, food, transport and sightseeing: Budget $70, Mid-range $195, Luxury $600 per person.
- 2Multiply
× days × travellers, one line per category.
- 3Add extras
Per traveller: 4-night Nile cruise ($350 / $700 / $1,800 by style), round-trip Cairo ↔ Luxor flight ($240), e-visa ($25).
- 4Convert
The live page shows 30+ currencies using ECB rates from Frankfurter, cached for 6 hours, with a built-in fallback for when it's offline.
- Accommodation$1,120
- Food & drink$560
- Local transport$350
- Sightseeing & guides$700
- Nile cruise (4 nights)$1,400
- Cairo ↔ Luxor flight$480
- Egypt e-visa$50
Payments through GetPayIn, verified twice
Card details are entered on GetPayIn's hosted checkout page, never on this site. The API decides how much to charge and signs the request. A booking is only marked paid after a signed webhook arrives from GetPayIn.
The server sets the price
There are three plans: deposit (a percentage the admin sets, 20 by default), full, and balance, which only opens once the deposit is paid. The API works out the amount from the booking. If the browser sends an amount more than a cent off, the request is rejected. If an unpaid order already exists for that booking, it's reused rather than duplicated.
A signed hand-off to hosted checkout
The API signs the request that starts the checkout with an HMAC. GetPayIn returns a checkout URL and the browser goes to GetPayIn's hosted page. Card details never reach this server. Payments can be in EGP, EUR or USD.
Only the webhook marks it paid
GetPayIn calls the API with an HMAC-SHA256 signature of the raw request body. If that header is missing, the API checks a signature field in the body instead, the same fallback GetPayIn's own plugin uses. Only after that check does the order switch to PAID. The booking's paid and due amounts then update, and its status becomes deposit_paid, or paid and confirmed.
The redirect is checked too
The customer comes back to /payment-return with a signed query string. The API verifies that signature before the page shows anything, so a tampered URL gets a verification error instead of a receipt.
Shipping it: two hosts, one domain
The front end builds and deploys on Vercel. The API and SQL Server run on MonsterASP.NET under IIS. Visitors and search engines see everything at egypt7000travel.com.
Front end on Vercel
Each build lists the current packages, bundles with Vite and prerenders the public pages. Vercel rewrites keep /api, uploaded images and the sitemap on the main domain. The sign-in pages send a Cross-Origin-Opener-Policy header that lets the Google sign-in popup work.
API and database on MonsterASP.NET
A PowerShell script builds a Release publish and uploads only the changed files over FTP. During the upload an app_offline.htm takes the site offline briefly so IIS can swap the files cleanly. Secrets come from the host's environment variables. The app won't start without a JWT key or the proxy list, and it applies EF Core migrations when it boots.
Making it findable on Google
robots.txt keeps crawlers out of the admin pages. The API generates sitemap.xml from the packages in SQL Server. Pages set canonical and Open Graph tags, plus JSON-LD structured data where it applies. Because the pages are prerendered, crawlers get the content without running JavaScript. The sitemap is submitted in Google Search Console.
One developer, front to back.
Design system, SPA, API, database, payments, translations, deployment and SEO, all live at egypt7000travel.com.
Visit egypt7000travel.com