Building a Flight Data Dashboard with Next.js and Server Components

How to build a flight data dashboard with Next.js and the App Router. Practical tutorial covering server components, API key security, live departures, Leaflet maps and Vercel deployment — using the AirLabs flight data API with complete code examples.

Author
Sergey St.
Share:

Why Next.js for a Flight Data Dashboard

If you are building a modern web application on top of a flight data API — a live tracker, an airport information site, a traveller-facing dashboard, an internal aviation tool — Next.js is one of the most productive stacks you can pick. Its React foundations mean the frontend developer community around it is large. Its App Router with Server Components solves the two problems most flight data applications immediately run into: keeping the API key off the browser, and delivering fast first paints without shipping heavy client bundles.

This guide walks through building a working flight data dashboard in Next.js — from the initial project setup to a deployed application with live departures, an interactive map and safe API key handling. Every code example uses documented AirLabs endpoints and standard Next.js 14+ App Router patterns. The result is a small but complete Next.js application that demonstrates the patterns any real production flight data dashboard would use.

"The reason Next.js is a good fit for a flight data dashboard has less to do with React and more to do with where the code runs. Server Components make it obvious that fetching the API happens on the server, that the key stays on the server, and that the browser only receives the rendered result. That is not a small thing. It removes an entire category of security bugs that plague single-page flight tracker builds."

Setting Up the Project

A minimal working setup takes three commands. Next.js scaffolds a new project through create-next-app; the App Router is the modern default.

npx create-next-app@latest flight-dashboard --typescript --app --tailwind
cd flight-dashboard

You do not need any additional dependencies to make requests to AirLabs — Next.js includes an enhanced fetch() with automatic caching and revalidation. For the map view later in this guide, we will add Leaflet:

npm install leaflet react-leaflet
npm install --save-dev @types/leaflet

Configure the API key in .env.local at the project root — this file is server-side only, and Next.js will refuse to expose it to the browser as long as its name does not start with NEXT_PUBLIC_:

# .env.local
AIRLABS_API_KEY=your_api_key_here

Get a free key from the free flight API plan. The environment variable is now available as process.env.AIRLABS_API_KEY in any Server Component or server-side code path.

The First Server Component — Flight Status

The clearest introduction to Server Components in Next.js is a component that fetches data from the server and renders it. Create app/flight/[flight_iata]/page.tsx:

async function getFlight(flightIata: string) {
  const url = new URL('https://airlabs.co/api/v9/flight');
  url.searchParams.set('flight_iata', flightIata);
  url.searchParams.set('api_key', process.env.AIRLABS_API_KEY!);
  const res = await fetch(url, { next: { revalidate: 60 } });
  const body = await res.json();
  if (body.error) throw new Error(body.error.message);
  return body.response;
}
export default async function FlightPage({
  params,
}: {
  params: { flight_iata: string };
}) {
  const flight = await getFlight(params.flight_iata);
  if (!flight) {
    return <div className="p-6">Flight not found</div>;
  }
  return (
    <main className="p-6 max-w-2xl mx-auto">
      <h1 className="text-2xl font-bold">
        {flight.flight_iata}: {flight.dep_iata} → {flight.arr_iata}
      </h1>
      <div className="mt-4 grid grid-cols-2 gap-4">
        <div>
          <div className="text-sm text-gray-500">Scheduled</div>
          <div>{flight.dep_time}</div>
        </div>
        <div>
          <div className="text-sm text-gray-500">Estimated</div>
          <div>{flight.dep_estimated}</div>
        </div>
        <div>
          <div className="text-sm text-gray-500">Gate</div>
          <div>T{flight.dep_terminal} / {flight.dep_gate}</div>
        </div>
        <div>
          <div className="text-sm text-gray-500">Delay</div>
          <div>{flight.delayed ?? 0} min</div>
        </div>
      </div>
    </main>
  );
}

The async function at the top of the component is unique to Server Components — you can await data directly before rendering. Visit /flight/BA117 in the browser and Next.js renders the page on the server, calls the AirLabs Flight Information API, and streams the rendered HTML to the browser. The API key never leaves the server.

The next: { revalidate: 60 } option is a Next.js caching directive: this exact request is cached for 60 seconds. If ten different users request the same flight in the same minute, only the first one triggers an actual API call. This matters for both cost and rate-limit resilience.

The Server-Client Split

Next.js applications combine two kinds of components. Server Components — like the flight page above — run on the server and can access secrets, databases and paid APIs directly. Client Components run in the browser and handle interactivity, forms, live updates and anything requiring useState or useEffect.

Marking a component as a Client Component is a one-line convention: put 'use client' at the top of the file. The decision of which to use is straightforward:

  • Use a Server Component for fetching flight data from AirLabs, for rendering static or infrequently updated content, and for anything that touches secrets
  • Use a Client Component for interactivity — dropdowns, forms, tabs, live-refreshing widgets, map interactions
  • Use both together — Server Component fetches initial data, passes it to a Client Component that handles the interactive behaviour

Most flight data dashboards need both. The initial airport board renders as a Server Component with fresh data; a Client Component wrapped around it handles the auto-refresh timer that updates the board every 30 seconds.

Building an Airport Departure Board

The most useful practical example is a departure board — the kind of live schedule display travellers use at airports and hotels. It combines the AirLabs Schedules API with a small amount of client-side interactivity for auto-refresh.

Create app/departures/[airport]/page.tsx:

import DepartureRefresher from './DepartureRefresher';

async function getDepartures(iata: string) {
  const url = new URL('https://airlabs.co/api/v9/schedules');
  url.searchParams.set('dep_iata', iata);
  url.searchParams.set('api_key', process.env.AIRLABS_API_KEY!);
  url.searchParams.set(
    '_fields',
    'flight_iata,airline_iata,arr_iata,dep_time,dep_estimated,dep_terminal,dep_gate,status,delayed,aircraft_icao',
  );

  const res = await fetch(url, { next: { revalidate: 30 } });
  const body = await res.json();
  return body.response || [];
}

export default async function DeparturesPage({
  params,
}: {
  params: { airport: string };
}) {
  const departures = await getDepartures(params.airport.toUpperCase());

  return (
    <main className="p-6 max-w-4xl mx-auto">
      <h1 className="text-3xl font-bold mb-6">
        Departures from {params.airport.toUpperCase()}
      </h1>
      <DepartureRefresher />

      <table className="w-full">
        <thead className="border-b">
          <tr>
            <th className="text-left py-2">Flight</th>
            <th className="text-left py-2">To</th>
            <th className="text-left py-2">Scheduled</th>
            <th className="text-left py-2">Estimated</th>
            <th className="text-left py-2">Gate</th>
            <th className="text-left py-2">Status</th>
          </tr>
        </thead>
        <tbody>
          {departures.slice(0, 30).map((f: any) => (
            <tr key={f.flight_iata} className="border-b">
              <td className="py-2">{f.flight_iata}</td>
              <td>{f.arr_iata}</td>
              <td>{f.dep_time}</td>
              <td className={f.delayed ? 'text-red-600' : ''}>
                {f.dep_estimated}
              </td>
              <td>{f.dep_terminal ? `T${f.dep_terminal}` : '-'} {f.dep_gate}</td>
              <td>{f.status}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </main>
  );
}

The Client Component DepartureRefresher provides a manual refresh button — Server Components cannot use useEffect, so any user-initiated refresh happens on the client. Create app/departures/[airport]/DepartureRefresher.tsx:

'use client';

import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';

export default function DepartureRefresher() {
  const router = useRouter();
  const [isPending, startTransition] = useTransition();
  const [lastRefresh, setLastRefresh] = useState(new Date());

  const refresh = () => {
    startTransition(() => {
      router.refresh(); // Re-fetches the Server Component
      setLastRefresh(new Date());
    });
  };

  return (
    <div className="flex items-center gap-3 mb-4 text-sm text-gray-500">
      <button
        onClick={refresh}
        disabled={isPending}
        className="px-3 py-1 border rounded hover:bg-gray-50"
      >
        {isPending ? 'Refreshing...' : 'Refresh'}
      </button>
      <span>Last updated: {lastRefresh.toLocaleTimeString()}</span>
    </div>
  );
}

router.refresh() re-runs the Server Component with fresh data, keeping the API key on the server and streaming only the updated HTML to the browser. This is the standard pattern for user-triggered refresh in Next.js.

For automatic updates without a user action, the recommended pattern in a production dashboard is not client-side polling — it is the AirLabs Flight Alert API, which pushes changes to your server via webhook. The webhook receiver pattern is covered later in this guide.

Adding an Interactive Map

For a live flight tracker view, the AirLabs Real-Time Flights API returns aircraft positions filtered by airport, airline or geographic bounding box. Rendering these on a Leaflet map is a common pattern.

Leaflet needs to run in the browser (it manipulates the DOM directly), so the map is a Client Component. First, fetch the initial data in a Server Component and pass it down:

// app/map/[airport]/page.tsx
import FlightMap from './FlightMap';

async function getNearbyFlights(iata: string) {
  const url = new URL('https://airlabs.co/api/v9/flights');
  url.searchParams.set('arr_iata', iata);
  url.searchParams.set('api_key', process.env.AIRLABS_API_KEY!);
  url.searchParams.set(
    '_fields',
    'flight_iata,lat,lng,alt,dir,dep_iata,arr_iata,aircraft_icao',
  );

  const res = await fetch(url, { next: { revalidate: 30 } });
  const body = await res.json();
  return body.response || [];
}

export default async function MapPage({
  params,
}: {
  params: { airport: string };
}) {
  const flights = await getNearbyFlights(params.airport.toUpperCase());
  return <FlightMap flights={flights} centre={params.airport.toUpperCase()} />;
}

Then the Client Component with Leaflet:

// app/map/[airport]/FlightMap.tsx
'use client';

import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import { useEffect, useState } from 'react';

type Flight = {
  flight_iata: string;
  lat: number;
  lng: number;
  alt: number;
  dir: number;
  dep_iata: string;
  arr_iata: string;
};

export default function FlightMap({
  flights: initial,
  centre,
}: {
  flights: Flight[];
  centre: string;
}) {
  const [flights, setFlights] = useState(initial);

  // Note: this fetches fresh data on user interaction, not on a timer.
  // For continuously updating dashboards in production, use the Alert API
  // webhook pattern shown below instead.
  const refresh = async () => {
    const res = await fetch(`/api/flights/${centre}`);
    if (res.ok) setFlights(await res.json());
  };

  const centreCoords: [number, number] = [40.6, -73.8]; // JFK area

  return (
    <div style={{ height: '100vh', width: '100%' }}>
      <button
        onClick={refresh}
        className="absolute top-4 right-4 z-[1000] bg-white px-3 py-1 rounded shadow"
      >
        Refresh
      </button>
      <MapContainer center={centreCoords} zoom={7} style={{ height: '100%' }}>
        <TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
        {flights.map((f) => (
          <Marker key={f.flight_iata} position={[f.lat, f.lng]}>
            <Popup>
              <strong>{f.flight_iata}</strong>
              <br />
              {f.dep_iata} → {f.arr_iata}
              <br />
              {f.alt.toLocaleString()} ft
            </Popup>
          </Marker>
        ))}
      </MapContainer>
    </div>
  );
}

Notice the Client Component fetches from a Route Handler at /api/flights/[airport] rather than calling AirLabs directly, because that would expose the API key. Notice also that this example refreshes on user interaction (a button click) rather than on a timer. Timer-based polling from every connected browser is expensive on the API quota and — importantly — is not the recommended pattern for continuous updates. The recommended pattern is the Alert API webhook, covered in a later section.

Route Handlers — The API Proxy Pattern

When a Client Component needs to fetch fresh data on demand — for polling, for user-triggered searches, for form submissions — the pattern is to create a Route Handler that proxies the request. The browser calls your Next.js route; the route calls AirLabs from the server; the API key stays hidden.

Create app/api/flights/[airport]/route.ts:

import { NextResponse } from 'next/server';

export async function GET(
  _request: Request,
  { params }: { params: { airport: string } },
) {
  const url = new URL('https://airlabs.co/api/v9/flights');
  url.searchParams.set('arr_iata', params.airport);
  url.searchParams.set('api_key', process.env.AIRLABS_API_KEY!);
  url.searchParams.set(
    '_fields',
    'flight_iata,lat,lng,alt,dir,dep_iata,arr_iata',
  );

  const res = await fetch(url, { next: { revalidate: 30 } });
  const body = await res.json();
  return NextResponse.json(body.response || []);
}

The route returns lean JSON to the browser. The Client Component consumes it and updates state. The AirLabs API key is only ever read on the server.

Environment Variables and Key Safety

Next.js has two categories of environment variables:

  • Names not starting with NEXT_PUBLIC_ — available only on the server. AIRLABS_API_KEY fits here.
  • Names starting with NEXT_PUBLIC_ — inlined into the browser bundle at build time. Never put an API key here.

The default is safe: process.env.AIRLABS_API_KEY referenced in a Server Component or Route Handler works correctly; referenced in a Client Component, it is simply undefined, because the value never reaches the browser. This forces the safe pattern by design.

For local development, use .env.local (Git-ignored by default). For Vercel deployment, add the environment variable through the Vercel project settings — it will be injected into server-side code paths only.

Deployment on Vercel

Vercel — the company behind Next.js — offers zero-config deployment. Push your project to a Git repository, connect it to Vercel, add the environment variable, deploy.

# Vercel CLI (optional)
npm install -g vercel
vercel
# Add environment variable through the dashboard or:
vercel env add AIRLABS_API_KEY

Server Components run in Vercel's serverless functions (or edge runtime, if configured). Each request to your dashboard triggers a server-side fetch to AirLabs — cached at the edge for the revalidate interval you specified — and streams the rendered HTML to the browser.

For dashboards handling meaningful traffic, review Vercel's caching options: the revalidate interval, the fetch() cache settings and Vercel's own edge cache combine to reduce the actual API request rate to AirLabs by orders of magnitude compared to naive per-request calls.

Receiving Alert Webhooks — The Recommended Real-Time Pattern

For continuously updating dashboards — a live board that reacts within seconds to gate changes, delays, status transitions — the correct pattern is not client-side polling but the AirLabs Flight Alert API. It is a webhook-based subscription: you register a listener for a set of flights and provide your server URL; AirLabs pushes an HTTP POST to that URL when any tracked field changes.

The Alert API is currently in beta and available on paid plans. Two Route Handlers cover the workflow — one to register a listener, one to receive the webhook callbacks.

Register a listener by calling listen and pass a webhook_url that includes a random secret in the path — that secret is the shared authentication between AirLabs and your handler:

// app/api/alerts/subscribe/route.ts
import { NextResponse } from 'next/server';

export async function POST(request: Request) {
  const { airline_iata, flight_number } = await request.json();

  const url = new URL('https://airlabs.co/api/v9/listen');
  url.searchParams.set('api_key', process.env.AIRLABS_API_KEY!);
  // The webhook URL includes a secret path segment known only to your server and AirLabs.
  url.searchParams.set(
    'webhook_url',
    `${process.env.PUBLIC_URL}/api/alerts/webhook/${process.env.WEBHOOK_SECRET}`,
  );
  url.searchParams.set('airline_iata', airline_iata);
  url.searchParams.set('flight_number', flight_number);

  const res = await fetch(url);
  const body = await res.json();
  // body: { response: { listener_id: 99 } }
  return NextResponse.json({ listener_id: body.response?.listener_id });
}

Store the WEBHOOK_SECRET in your environment variables — generate it once with openssl rand -hex 32 or Node's crypto.randomBytes(32).toString('hex') and never change it while listeners are active (the URL is baked into each subscription).

Receive the webhook callbacks with proper source verification:

// app/api/alerts/webhook/[secret]/route.ts
import { NextResponse } from 'next/server';

// Refresh this list periodically from https://airlabs.co/webhook_ips.txt
// A background job or config service is the appropriate place for this.
const AIRLABS_WEBHOOK_IPS = new Set([
  // Populate from airlabs.co/webhook_ips.txt
  // e.g. "185.199.108.153", "185.199.109.153", ...
]);

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!;

export async function POST(
  request: Request,
  { params }: { params: { secret: string } },
) {
  // 1. Secret in URL — the listener was registered with this exact path,
  //    so only AirLabs (which received the URL at Listen time) knows it.
  if (params.secret !== WEBHOOK_SECRET) {
    return new NextResponse('Unauthorized', { status: 401 });
  }

  // 2. IP allowlist — defense in depth. Vercel forwards the client IP
  //    in `x-forwarded-for`; other platforms may use `x-real-ip`.
  const forwarded = request.headers.get('x-forwarded-for') || '';
  const clientIp = forwarded.split(',')[0].trim();

  if (AIRLABS_WEBHOOK_IPS.size > 0 && !AIRLABS_WEBHOOK_IPS.has(clientIp)) {
    return new NextResponse('Forbidden', { status: 403 });
  }

  const payload = await request.json();
  const { listener_id, changed, flight } = payload;

  console.log(
    `Flight ${flight.flight_iata} changed: ${changed.join(', ')}. ` +
    `Status: ${flight.status}, arr_delayed: ${flight.arr_delayed}`,
  );

  // Persist the change, notify users, update your database, revalidate cached pages, etc.
  // For example, to invalidate a cached Next.js page:
  // revalidatePath(`/flight/${flight.flight_iata}`);

  return NextResponse.json({ ok: true });
}

Two layers of source verification are in play here, both important:

The secret in the URL path is the primary defence. When you register a listener, the webhook_url you provide includes a long random secret — for example https://yourapp.com/api/alerts/webhook/f9a2c1e8b3d4.... Only AirLabs and your server know this URL. An attacker who guesses /api/alerts/webhook still cannot POST to your handler because they do not know the secret. Generate the secret with a cryptographically-random function (crypto.randomBytes(32).toString('hex') in Node.js) and store it in an environment variable.

The IP allowlist is defence in depth. AirLabs publishes the list of IP addresses their webhook servers originate from at airlabs.co/webhook_ips.txt and asks that you allow-list them in your systems. This does not replace the secret — an IP allowlist alone is not enough because IPs can shift and lists can go stale — but combined with the URL secret it makes source spoofing effectively impossible.

The changed array in the payload lists which fields updated, so you only process what actually changed — a gate change, a new estimated time, a status transition. Notice the payload uses dep_delayed and arr_delayed as separate fields (the legacy delayed field is deprecated).

The subscription persists until you call unlisten with the listener_id. Store the listener IDs in your database so you can unsubscribe when a booking is completed, a traveller lands or a user unfollows a flight.

What Next.js Does Not Solve

Being clear about scope avoids surprises later. Next.js handles rendering, caching, deployment and the server-client split — it does not solve every concern of a live aviation dashboard:

  • Automatic UI refresh on webhook receipt. Receiving a webhook updates your server-side state. Pushing that update to a connected browser tab requires a separate real-time layer — Server-Sent Events, WebSockets, or a service like Pusher/Ably. Alternatively, revalidate the affected Next.js page with revalidatePath() so the next server render or navigation shows fresh data.
  • Rate limit management under viral traffic. Vercel's edge cache and fetch() revalidation absorb most of the load, but a genuinely viral moment can still exceed the AirLabs plan's request quota. Plan capacity with the caching layer in mind, and remember that webhook deliveries also count against the quota.
  • Listener state persistence. The listener_id values returned by the Alert API must be stored in your own database, and unsubscribed when no longer needed. Next.js does not handle this for you.
  • User authentication. Next.js has excellent auth options (Auth.js, Clerk, Vercel's own auth), but these are your choice — the flight data layer is decoupled from user identity.

Practical Notes for Building the Dashboard

  • Always keep AIRLABS_API_KEY server-side. Reference it in Server Components and Route Handlers, never in Client Components. Next.js will not accidentally expose it as long as you follow the naming convention.
  • Use revalidate for server-side caching, not client-side polling. A revalidate: 30 on a Server Component fetch caches the response for 30 seconds across all users; a setInterval in a Client Component fires a new request from every browser tab. The first is cheap and effective; the second is expensive.
  • For continuously updating dashboards, use the Alert API. The webhook-based push pattern uses quota only when data actually changes, unlike polling which consumes quota constantly.
  • Use _fields to keep responses lean. The _fields parameter on AirLabs endpoints keeps responses small and page renders fast.
  • Prefer Server Component fetching for initial data. Client Component fetching adds a browser round-trip and is slower for the first paint. Server-fetch initial data, then let the Client Component handle user-triggered updates.
  • Verify webhook sources. A public POST endpoint that trusts any incoming request is a vulnerability — attackers can push fake status updates, DDoS your handler or trigger unintended actions. Combine a secret path segment in the webhook_url you register with an IP allowlist against airlabs.co/webhook_ips.txt. Do not skip this because the deployment is on Vercel — Vercel accepts arbitrary HTTP just like any other host.
  • Read dep_delayed and arr_delayed separately. The Alert API webhook payload uses two distinct fields for departure and arrival delay; the combined delayed field is deprecated.
  • Handle empty responses gracefully. Some airport-flight-code combinations return no data. Always check for empty arrays and unavailable fields before rendering.
  • Deploy an error boundary. A single failed API call should not crash the whole dashboard — use Next.js error.tsx files at the route level to isolate failures.
  • Test on real data early. The AirLabs free plan covers development and small-scale testing; upgrade before launching to production traffic. Note that the Alert API is beta and available on paid plans only.

Flight Data Dashboards in Next.js

Whether you are building a public flight tracker, an internal airline dashboard, a passenger-facing information tool or a startup MVP, the AirLabs API and Next.js App Router give you the shortest path from a prototype to a deployed, secure, production-shaped application. Every endpoint AirLabs offers works cleanly with the Server Component fetching pattern, and the resulting architecture keeps the API key safe by construction rather than by discipline.

Supported API Features

Our Developer API allows you to create a custom experience for your users and increase the value of your product:

  • Real-Time Flights API for live aircraft positions with tail number, ICAO hex, altitude, speed and heading
  • Flight Information API for detailed status per flight — scheduled, estimated, delayed, gate, terminal, aircraft
  • Schedules API for departures and arrivals at any airport, with codeshare fields
  • Flight Delays API for currently delayed flights at a specific airport
  • Flight Alert API for webhook-based notifications on flight-level field changes
  • NearBy API for airports within a geographic radius
  • Name Suggestion API for autocomplete of airport, city and country names
  • Reference databases for Airports, Airlines, Cities, Fleets, Routes, Countries and Timezones
  • Field selection via _fields for lean, targeted responses
  • JSON, XML and CSV response formats behind a single API key

You can try it right now without any obligation! Get a free flight API plan and see for yourself that we have exactly the data you need!

If you need more information, don't hesitate to contact us. We are always happy to chat with our customers and are sure to find a customized solution for each request.

Ready to get started?

Explore AirLabs, or create an account instantly and start using API.

Get FREE API Key