Gopaxo

· by Rédaction

Technical Architecture: Building a Travel Comparator with Next.js 14 and Strict TypeScript

Discover how we developed our travel comparison platform using Next.js 14 App Router and Strict Mode TypeScript to guarantee performance and reliability.

Introduction: Why a Rigorous Technical Stack for Travel

In the online tourism sector, data reliability is as crucial as display speed. A travel comparator handles complex flows: train schedules, dynamic pricing, real-time availability, and multiple currencies. A type error in the code can result in a ticket sold at the wrong price or an incorrect schedule, which is unacceptable for the end user.

That is why, for the redesign of our platform in 2026, we opted for an architecture based on Next.js 14 with the App Router, coupled with a Strict Mode TypeScript configuration. This combination allows us to benefit from the advantages of server-side rendering (Server Components) for SEO, while enforcing type safety that drastically reduces bugs in production. According to the official Next.js documentation, the App Router represents the fundamental evolution for managing routes and layouts, allowing fine-grained control over caching and data streaming.

Technical configuration of a modern Next.js project

In this article, we will detail the technical choices underlying our comparator, focusing on TypeScript configuration, data management via Server Actions, and web vital performance optimization.

Next.js 14 and the App Router: The Heart of the System

Adopting the App Router in Next.js 14 marks a paradigm shift compared to the historical Pages Router. For a travel comparator, where the page structure is hierarchical (Home > Destination > Transport > Detail), the file-system-based routing system of the App Router offers indispensable clarity.

Server Components by Default

One of the major advantages is the use of React Server Components (RSC) by default. In our comparator, search result lists (trains, buses, flights) are rendered on the server. This means the data fetching code executes directly on the server, close to the database or third-party APIs (SNCF, DB, Eurail). This eliminates the need for a loading state visible to the user for the initial content and improves the Largest Contentful Paint (LCP), a key metric of the Core Web Vitals defined by Google.

Unlike previous approaches where the client had to fetch data via useEffect, we now use async components directly within the route hierarchy. Here is a simplified example of our results page:

// app/search/results/page.tsx
import { searchTrips } from '@/lib/api';
import { TripList } from './trip-list';

export default async function SearchResults({ searchParams }: { searchParams: { from: string; to: string } }) {
  const trips = await searchTrips(searchParams.from, searchParams.to);
  
  return (
    <main>
      <h1>Results for {searchParams.from} to {searchParams.to}</h1>
      <TripList trips={trips} />
    </main>
  );
}

This approach reduces the JavaScript bundle sent to the client, as the fetch logic is not included in the code downloaded by the browser. For a user on a mobile network while traveling, this savings in data and processing time is significant.

Managing Nested Layouts

Our application structure requires persistent layouts, such as the navigation bar and footer, which should not reload when navigating between results pages. The App Router handles this natively via the layout.tsx file. Additionally, we can have specific layouts for certain sections, such as the "Nightjet" or "Eurail" section, allowing us to inject specific scripts or styles without affecting the rest of the application.

Strict TypeScript: An Essential Safety Net

Using TypeScript is one thing; using it in strict mode is another. In our tsconfig.json, we enable all strictness options. This includes noImplicitAny, strictNullChecks, and strictFunctionTypes. For a comparator aggregating data from multiple heterogeneous APIs, type safety is our first line of defense.

tsconfig.json Configuration

Here is the base of our TypeScript configuration, aligned with 2026 recommendations for enterprise projects:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [{ "name": "next" }]
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

The strict: true option forces the developer to explicitly handle cases where a variable could be undefined or null. In the travel context, a price might be null (free offer), a departure time might be missing (incomplete journey). TypeScript forces us to handle these cases explicitly, avoiding classic errors like Cannot read property of undefined.

Typing Environment Variables

A common error in Next.js projects is the untyped use of environment variables. We use a library like t3-env or a custom Zod schema to validate variables at server startup. This ensures that if an API key for a train provider (e.g., DB or SNCF API) is missing, the application refuses to start rather than failing silently in production.

// env.ts
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";

export const env = createEnv({
  server: {
    DATABASE_URL: z.string().url(),
    RAIL_API_KEY: z.string().min(1),
  },
  client: {
    NEXT_PUBLIC_ANALYTICS_ID: z.string().min(1),
  },
  runtimeEnv: {
    DATABASE_URL: process.env.DATABASE_URL,
    RAIL_API_KEY: process.env.RAIL_API_KEY,
    NEXT_PUBLIC_ANALYTICS_ID: process.env.NEXT_PUBLIC_ANALYTICS_ID,
  },
});

This practice, recommended by the Vercel community and Next.js maintainers, ensures our configuration is as robust as our code.

Data Validation with Zod and Server Actions

In an App Router architecture, Server Actions often replace traditional API Routes for data mutations (such as booking or saving a price alert). However, arguments passed to a Server Action are not typed by default during the client call. This is where schema validation becomes critical.

We use Zod to validate user inputs before any business logic processing. This protects against injections and malformed data. For example, when searching for a trip, we must ensure dates are valid and station codes exist.

Data validation and server flow

Example of a Typed Server Action

// actions/search.ts
'use server';

import { z } from 'zod';
import { db } from '@/lib/db';

const SearchSchema = z.object({
  origin: z.string().length(3), // IATA or UIC Code
  destination: z.string().length(3),
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  passengers: z.number().min(1).max(9),
});

export async function searchTrip(formData: FormData) {
  const rawData = {
    origin: formData.get('origin'),
    destination: formData.get('destination'),
    date: formData.get('date'),
    passengers: Number(formData.get('passengers')),
  };

  const result = SearchSchema.safeParse(rawData);

  if (!result.success) {
    return { error: 'Invalid data', details: result.error.flatten() };
  }

  // Secure business logic processing
  const trips = await db.trip.findMany({
    where: {
      origin: result.data.origin,
      // ... other filters
    }
  });

  return { success: true, data: trips };
}

By combining strict TypeScript and Zod, we obtain static validation (at compilation) and dynamic validation (at runtime). This is what we call "end-to-end type safety". According to 2026 TypeScript best practices, this double validation is essential for critical applications.

Performance and SEO: The Comparator's Stakes

A travel comparator lives by its organic search ranking (SEO). Users search for "Train Paris Berlin" or "Nightjet Brussels". Next.js 14 excels in this area thanks to Static Site Generation and hybrid dynamic rendering.

Image and Font Optimization

We use the next/image component to automatically optimize visuals of trains and destinations. This allows serving modern formats like WebP or AVIF depending on the user's browser. Additionally, using next/font to load fonts (such as Inter or Roboto) eliminates layout shift (CLS), ensuring text does not move during loading.

Streaming and Suspense

For complex results pages, we use React Suspense. This allows displaying the page skeleton immediately (header, filters) while search results, which may take a few seconds to aggregate from multiple providers (Ouigo, TGV Inoui, DB), load in the background. The user can start interacting with filters even before the full list of trains is displayed.

This technique significantly improves Time to Interactive (TTI). In a competitive context where every second counts for conversion, this technical optimization translates directly into an increased booking rate.

Internationalization (i18n) and Maintenance

Our comparator targets a European audience. Managing multiple languages (French, German, English, Spanish) is native in our route structure. Next.js allows prefixing routes by locale (/fr/search, /de/search).

With TypeScript, we type translation keys. This means if a developer adds new text in the interface but forgets to update the German translation file, the compiler will raise an error. This avoids embarrassing situations where part of the interface remains in English on a page intended for the German market.

Code maintenance is also facilitated by the modularity of the App Router. Each feature (search, payment, user account) is isolated in its own folder with its own tests. We use Vitest for unit tests and Playwright for end-to-end tests, ensuring that dependency updates (such as the migration to React 19 planned in the Next.js ecosystem) do not break existing features.

Conclusion: A Solid Foundation for the Future

Choosing Next.js 14 and strict TypeScript is not just a technical trend; it is an operational necessity for a travel comparator in 2026. The complexity of rail and air data requires a rigor that static typing naturally imposes. The App Router offers the flexibility needed to compose rich interfaces while maintaining top-tier performance.

By following Next.js documentation recommendations and applying strict code quality standards, we have built a platform capable of evolving. Whether integrating new providers like extended Nightjet lines or supporting new European regulations on passenger data, our architecture is ready. Technology here serves the user experience: a faster, more reliable, and more secure site to prepare your next trips across Europe.

To go further, we recommend consulting the official Next.js documentation on the App Router and the TypeScript configuration guide for regular updates on emerging best practices.

Popular train routes

Book cheap and premium train tickets on the most searched routes.