Back to Case Studies
TravelTechPublished Updated 11 min read

Replacing a Spreadsheet With an eVisa Platform at TravelRox

Led architecture and delivery of the eVisas platform at TravelRox: a no-code conditional visa form builder, a Kanban operations portal, customer web and mobile apps sharing one rule engine, and a Terraformed AWS backend.

Nest.jsNext.jsAWSTerraformSystem DesignTechnical Leadership
The spreadsheet the business ran on, and the Kanban board that replaced it: every application with a stage, an owner and a deadline that pauses when the wait is the customer's.
Project Type
TravelTech, Platform Engineering, Workflow Automation
Role
Head of Software Engineering
Tech-stack
NestJS, PostgreSQL, Drizzle ORM, Zod, Next.js, React, Vite, TanStack Router, React Native, Expo, Turborepo, AWS (ECS Fargate, RDS, S3, CloudFront), Terraform, GitHub Actions

TravelRox Inc. sells eVisas. A traveller picks a destination, answers a set of questions, uploads a passport, pays, and waits for a government to approve the application. The company had customers and revenue. It ran them out of a spreadsheet.

Applications arrived through a WordPress form and landed in one Google Sheet. The operations team worked visas from inside that sheet, assigning them, chasing missing documents, tracking which ones were close to their deadline. As the business grew, the sheet grew with it, until it took seconds to open and no one could answer a basic question: which applications are stuck, and with whom?

I joined as Head of Software Engineering and built the replacement: an operations portal, a customer web portal, iOS and Android apps, the backend API, the database, and the AWS infrastructure underneath. I architected all of it, wrote most of it, then hired and led the designer and mobile developer who took the apps to the stores.

Understanding the Requirement

The brief I received asked for an admin panel to replace the Google Sheet. I spent the first weeks with the operations team instead of the code editor, and found a harder constraint underneath.

Every visa needs a different form. A Bangladeshi applying to the UAE submits different documents than a Briton applying to Egypt. The requirements also shift based on what the applicant answers. A salaried employee needs an NOC from their employer, a business owner needs proof of bank solvency, a minor needs a guardian's consent letter. There are hundreds of nationality and destination pairs, and governments change their rules without notice.

Hard-coding those forms would turn every rule change into a support ticket, a developer's afternoon, and a deployment. The business would move at the speed of its engineering team, which at that point meant me. So I designed around one requirement: an operations manager with no technical background should be able to change a visa form on a Tuesday afternoon without calling an engineer. Most of the architecture below follows from that.

Goals

  • Replace the spreadsheet with a Kanban operations portal where every application has an owner, a stage, and a deadline
  • Let non-technical admins build conditional visa forms themselves, with no code and no deployment
  • Give customers a fast portal and mobile apps to apply, save progress, and track status
  • Protect passports and bank statements to the standard sensitive personal data requires
  • Ship to production on a tight schedule without building something the company would have to throw away at scale

Architecture Decisions

🧩 A Modular Monolith

The delivery window was tight and the team was small. Microservices would have bought scaling headroom the business did not need yet, and charged for it in deployment complexity, network failure modes, and distributed debugging.

I built a modular NestJS application: one deployable, organised internally as eighteen self-contained modules such as form-builder, visa-applications, payments, customer-uploads, and visa-packages. Each owns its controller, service, repository, and DTO layer. Customer and admin APIs share the codebase behind separate route namespaces and separate guards.

I kept the module boundaries strict. Business logic stays in services, database access stays in repositories, and no module reads another module's tables. When traffic justifies the split, an engineer can lift a module into its own service without untangling it first. I traded scaling headroom for delivery speed and kept the option open, which is the tradeoff that costs a company a rewrite when someone makes it carelessly.

🗄️ PostgreSQL for Relational Data

Applicants belong to accounts, applications reference packages, packages reference countries, fields reference sections, conditions reference fields. The data is relational, and the integrity guarantees matter when the records are legal documents.

I used PostgreSQL with Drizzle ORM and pushed correctness into the schema rather than trusting application code to remember it: foreign keys with cascade rules, CHECK constraints on every enum-like column, composite indexes matching the queries the portals run, and a partial unique index enforcing one published form template per destination. The database refuses to break that business rule even if the application layer tries.

The Hard Part: A Form Builder for Non-Engineers

The platform turns on this piece, and I spent the most design time here.

📐 Modelling Forms as Data

A visa form lives in the database as a tree: template → steps → sections → fields. Each field carries its own type, validation rules, and display attributes. I built over twenty field types, from ordinary text and date inputs to domain-specific ones like PASSPORT, FORMAL_PHOTO, and ADDRESS with Google Maps lookup.

The form builder an operations admin actually uses. Steps and pages on the left, the field-type palette on the right, and no code anywhere in the loop.

I versioned and staged templates through DRAFT, PUBLISHED, UNPUBLISHED, and ARCHIVED, so an admin can build next month's Egypt form while this month's serves live traffic. Every submitted application records the template version it was filled against, so an application from March still renders correctly after someone changes the form in April. That matters when a government queries a submission months later.

🔀 Conditional Logic as a Rule Engine

Conditional behaviour lives in a field_conditions table. Each row holds a JSON rule with an action type, a priority, and a nested AND/OR/NOT condition tree. Six action types cover what admins need, including visibility, requirement, validation, options filtering, and value auto-fill, across roughly twenty-five operators spanning string matching, set membership, date comparison, regex, and age arithmetic derived from a date of birth.

Rules can reference the selected visa package alongside other form answers. A condition written as $package.requirements.invitationLetterRequired reads a property off the chosen package, so "ask for an invitation letter when this visa type requires one" becomes configuration instead of code.

Admins never type this JSON. In the admin portal they get a drag-and-drop builder with a visual condition editor that offers only the operators valid for the field type in front of them. A select field cannot be compared with greater_than, so the builder never shows that option.

A visibility rule in the customer portal: answering Yes reveals the follow-up question. The admin who wrote that rule never touched code.

Why this mattered commercially

Adding a destination country became an operations task rather than an engineering project. The operations team could open a new market in an afternoon while the engineering team worked on something else. The form builder let the platform scale with the business instead of with my calendar.

🧮 Pricing, Packages, and the Nationality Matrix

Pricing needs more than one number. A visa package combines destination, travel authorization type, visa type, entry type, and processing speed. Each carries its own government fee and service fee, in its own currency, with its own processing time and validity duration. I stored fees as integers in minor units to keep floating-point rounding away from money, and handled currency conversion at display time.

Above that sits a nationality and destination matrix deciding which destinations a given passport holder can apply for, including residency-permit rules for applicants living outside their country of citizenship. The customer portal detects the visitor's location, pre-selects their nationality, and shows only destinations open to them. I got that UX out of the data model rather than out of extra frontend logic.

The nationality matrix as the traveller sees it: only destinations an Indonesian passport can apply for, with residency-permit cases flagged.
The same rules from the admin side: passport rank, how many destinations each nationality can apply to, and whether the country is itself a destination.

Turning Operations Into a Workflow

The admin portal, built with React 19, Vite, and TanStack Router, replaced the spreadsheet with an explicit workflow. I implemented the authentication and admin invitation flow, pricing and currency management, the nationality matrix, package creation, the form builder, and the Kanban board the operations team works in.

Applications move through an eleven-stage lifecycle, from PAYMENT_PENDING and PAYMENT_VERIFIED through DATA_COLLECTION, VOM_APPROVAL, SCHEDULED, ON_PROCESS, and APPROVED, with DISAPPROVED, CANCELED, DISPUTED, and REFUNDED as terminal branches. Each card carries an assignee, a deadline, and notes.

The board that replaced the spreadsheet. Each column is a lifecycle stage, each card carries its assignees and SLA countdown, and a paused card stops that countdown.

SLA pause tracking is the detail I like most. When an application waits on the customer for payment or a missing document, the clock should stop running against the operations team. Stages like PAYMENT_PENDING and DATA_COLLECTION pause the SLA automatically, and the application records total paused time, so deadlines measure work the team owed rather than time it spent waiting. Managers can hold the team to a number that is fair to hold them to.

The customer side of SLA pausing. While an application waits on the traveller, the countdown stops and the card says so.

Customers see a simpler picture. The eleven internal stages collapse into six customer-facing statuses, keeping operational detail inside the company.

One Product, Three Clients

The customer portal ships on web, iOS, and Android with the same functionality. Three separate implementations would mean writing every rule three times, especially the conditional form logic, and watching them drift apart.

I put the customer side in a Turborepo monorepo: a Next.js web app and an Expo React Native app sharing packages for the API client, the form engine, validation schemas, data-fetching hooks, and utilities. Only the presentation layer differs between them.

One file concentrates the payoff. The condition engine decides whether a field is visible, required, disabled, or auto-filled, in roughly 350 lines of TypeScript with no UI dependencies, and all three platforms run that same code. A visa rule cannot behave one way on Android and another on the web, because only one implementation of it exists.

On the customer side I built authentication and onboarding, location-based nationality detection, destination browsing, the dynamic form renderer, partial draft saving so a half-finished application survives a closed tab, and multi-applicant support.

Multi-applicant needed its own modelling. One account holds several traveller profiles, such as a parent applying for a spouse and children, and fields behave differently across a group. Each field declares a group behaviour: PER_APPLICANT for passport numbers, COPY_FROM_PRIMARY for a shared home address that stays editable, and SHARED for single values like a travel date. A family of four fills one form.

The Expo app on the left resumes a half-finished application; on the right, one account picks which traveller profile to apply for. Both screens run the same form engine as the web portal.

Security Where It Counts

The system holds passports, photographs, and bank statements. A leak here would end the company.

  • Documents live in private S3 buckets. The API issues short-lived signed URLs per request, so a leaked link expires on its own
  • The API checks ownership against the requesting customer before issuing any URL, so a customer cannot fetch another customer's passport by guessing a key
  • The ownership check blocks path traversal explicitly. An S3 key is not a filesystem path, and a crafted key like base/../other/file would pass a naive prefix check while pointing somewhere else
  • Uploads are validated on MIME type, file size, and count per upload purpose, from central configuration rather than scattered literals
  • A regex constrains any identifier the code interpolates into a storage key, so user input cannot shape a path
  • Authentication uses JWTs in HTTP-only cookies with refresh-token rotation, separated for admin and customer, keeping tokens out of reach of browser scripts
  • Zod schemas in strict mode validate every request body and reject unknown properties

Infrastructure as Code From Day One

I built the AWS platform with Terraform as reusable modules covering VPC, RDS, ECS, ECR, ALB, S3 with CloudFront, Route 53, Secrets Manager, and GitHub OIDC, then composed them into separate staging and production environments from the same code.

The API runs on ECS Fargate behind an Application Load Balancer, with RDS PostgreSQL in private subnets and documents served through CloudFront with signed URLs. GitHub Actions handles CI and deployment and authenticates to AWS through OIDC instead of long-lived access keys, so the repository stores no AWS credentials. Staging deploys from release branches automatically. Production waits for a manual approval.

Building this at the start rather than later kept staging and production from drifting apart, and means anyone can rebuild the whole platform from source.

Building the Team

Partway through, the scope outgrew one person. I interviewed candidates, hired a UI/UX designer and a React Native developer, and led both.

The monorepo made onboarding cheap. The mobile developer inherited a working API client, a tested condition engine, and shared validation schemas, so the job in front of them was the interface rather than the business rules. The apps reached the App Store and Play Store without a second copy of visa logic to keep in sync.

I also wrote the engineering standards the codebase is held to: naming conventions across database, API, and TypeScript layers, module structure, validation patterns, and documented business rules explaining why each constraint exists. New contributors read the conventions instead of reverse-engineering them.

Results

  • A spreadsheet became a platform. Operations moved from a lagging Google Sheet to a Kanban workflow with owners, stages, SLAs, and pause-aware deadlines
  • New markets no longer need engineers. Adding a destination or changing a visa requirement is an admin task
  • One product on three clients, with the business logic written once and shared across web, iOS, and Android
  • Live in production at evisas.com, with apps published on both stores
  • A modular backend on a fully Terraformed AWS platform, with staging and production built from the same code
  • A team and a codebase that outlast me, with documented conventions, infrastructure as code, and module boundaries drawn where a future split will need them

The form builder was the most satisfying thing to design, though it is not the work I value most here. I spent the first weeks asking why the spreadsheet hurt, and the answer turned out to be about how fast the company could change its own rules. Everything else followed from taking that seriously.

Let's Work Together

Have a project in mind? Get in touch and let's discuss how I can help bring your ideas to life.

Get in Touch