Flight Kit

FlightSearchForm

Stage 1 of the booking flow. The form collects trip type, airports, dates, travellers and cabin, and publishes it to the fk-ui store as one object. It fetches nothing except the airport lookup, and it does not know a results list exists.

Submitting either composite below publishes the criteria and routes to /search-result with the search on the query string. The request itself is issued there — see Where the search happens below.

The form

Where the search happens

On the results page, not here. FlightResultsList subscribes to the store and issues POST /api/flight-kit/flights/search — through this app's own proxy, with the agency key attached server-side — then owns the wait, the abort and the error. It is mounted at /search-result, and it is the only component in this app that calls the search endpoint.

That split is the point of the handoff. A form that fetched would have to own a wait, an abort and an error on a page with nowhere to show them — and the results page would then need the same work again. One owner, one request, one place the wait lives. Step 5 of the guide below is the wiring.

The summary

The same search, read back in one line with the form behind an Edit button — and hidden again the moment a search is submitted, so the results are not pushed off screen.

Departure→Destination

6 Oct – 13 Oct 20261 PaxEconomy

Implementation guide

Six steps to mount these composites in a consumer app — including the route on submit, and where the fetch belongs — then everything both of them take.

  1. Import the stylesheet once

    One line in your global stylesheet. It pulls in Tailwind, the fk tokens, the fk-* utility vocabulary, and the @source directive that stops Tailwind purging the classes used inside the library. Leaving it out is the most common way a Tailwind component library renders unstyled.

    app/globals.css
    /* once, in your global stylesheet */
    @import '@sheriph/fk-ui/tailwind.css';
  2. Wrap the app in FlightKitProvider

    Mount it once, above every composite. The store is a module singleton, but the provider is what supplies the API client and the query cache, and it is where persisted defaults are restored — in an effect, never during render. agencyId is required; apiBase defaults to /api/flight-kit.

    app/providers.tsx
    'use client';
    
    import type { ReactNode } from 'react';
    import { FlightKitProvider } from '@sheriph/fk-ui/core';
    
    export function Providers({ children, agencyId }: { children: ReactNode; agencyId: string }) {
      return (
        // A same-origin path: your route handler attaches the API key there, so it
        // never reaches the browser.
        <FlightKitProvider agencyId={agencyId} apiBase="/api/flight-kit">
          {children}
        </FlightKitProvider>
      );
    }
  3. Keep the API key on your server

    The key belongs to the server. This app attaches it in app/api/flight-kit/[...path]/route.ts, and the browser only ever talks to that same-origin path. It must not appear in browser code, in a URL, or behind a NEXT_PUBLIC_* name — anything a client component reads is inlined into the JavaScript at build time, which would mean rebuilding to point it at a different agency.

  4. Mount the composites

    FlightSearchForm collects and publishes; FlightSearchSummary reads the same value back with the form behind an Edit button. Both write the same store key, so whichever one a traveller uses, the next stage sees the same thing. onSearch fires after the store update, so it is safe to route or log on it.

  5. Route to the results page from onSearch

    The form never navigates. It publishes the criteria to the store and reports them through onSearch, which fires after the store update — so your handler routes without racing the publish, and the visitor lands on the results composite.

    Stamp the search onto the query string while you are there. The params mirror the wire shape, so the results page can rebuild the request from the URL alone — which is what makes a link shareable and survivable: a cold load with no store state still knows what to search for.

    There is no library helper for this mapping. Serialising your URLs is not a composite's job, and a fixed query vocabulary would be a public contract to keep forever — an agency with a different route shape would be stuck with ours. The sample below is the whole of it, and this app keeps those same two functions in src/lib/search-query.ts so its two pages cannot disagree about the format.

    app/landing-hero.tsx — publish, then route
    'use client';
    
    import { useCallback } from 'react';
    import { useRouter } from 'next/navigation';
    import { FlightSearchForm, type SearchCriteria } from '@sheriph/fk-ui/search';
    
    /** Where the results composite is mounted. Pick one path and keep it. */
    const RESULTS_PATH = '/search-result';
    
    /**
     * `YYYY-MM-DD` in the visitor's own timezone.
     *
     * Never `toISOString().slice(0, 10)`: that is UTC, so a visitor east or west of
     * Greenwich would share a link that books the day before or after.
     */
    function toWireDate(date: Date): string {
      const month = `${date.getMonth() + 1}`.padStart(2, '0');
      const day = `${date.getDate()}`.padStart(2, '0');
      return `${date.getFullYear()}-${month}-${day}`;
    }
    
    /**
     * A submitted search as a shareable query string.
     *
     * The params mirror what `POST /flights/search` receives, so the results page can
     * rebuild the request from the URL alone — which is what makes a link work on a
     * cold load, before any store state exists.
     */
    function toQuery(criteria: SearchCriteria): string {
      const query = new URLSearchParams();
    
      if (criteria.from) query.set('origin', criteria.from.iataCode);
      if (criteria.to) query.set('destination', criteria.to.iataCode);
      if (criteria.dates.start) query.set('departureDate', toWireDate(criteria.dates.start));
      if (criteria.tripType === 'round-trip' && criteria.dates.end) {
        query.set('returnDate', toWireDate(criteria.dates.end));
      }
    
      query.set('adults', String(criteria.passengers.adults));
      query.set('children', String(criteria.passengers.children));
      query.set('infants', String(criteria.passengers.infants));
      query.set('cabin', criteria.cabin);
    
      return query.toString();
    }
    
    export function LandingHero() {
      const router = useRouter();
    
      return (
        <FlightSearchForm
          onSearch={useCallback(
            (criteria: SearchCriteria) => {
              // The form has already published to the store by now, so the results
              // page has everything it needs before it looks at the URL. The URL is
              // what makes the search shareable.
              router.push(`${RESULTS_PATH}?${toQuery(criteria)}`);
            },
            [router],
          )}
        />
      );
    }
  6. The results page fetches — FlightResultsList owns it

    On the path you routed to, mount FlightResultsList. It subscribes to the store, aborts a superseded search, and owns loading, empty and error. That is a stage 1 and stage 2 line-up complete: a form that publishes, a route that carries the search, and a list that fetches.

    Only if you are not using it do you fetch yourself. When onSearch fires the criteria are already in the store, so your handler reads them back, calls client.searchOffers and maps the response with mapTravelportOfferings. Keeping the fetch there is what lets you decide when to show a wait and what to do with an error — and it is why the store only ever holds searchId and the offer refs rather than a multi-MB catalog.

    app/search-stage.tsx — the manual path: publish, fetch, map, log
    'use client';
    
    import { useCallback, useState } from 'react';
    import { mapTravelportOfferings } from '@sheriph/flight-kit-shared/transformers';
    import { FlightKitApiError, useFlightKit, useFlightKitStore } from '@sheriph/fk-ui/core';
    import { FlightSearchForm } from '@sheriph/fk-ui/search';
    
    /**
     * The other half of the handoff: the form publishes, YOU fetch.
     *
     * `onSearch` fires *after* the store is updated, so the store is the source of
     * truth for what to send — `requestId` included, which is what makes two
     * identical searches two searches rather than one.
     */
    export function SearchStage() {
      const client = useFlightKit();
      const [error, setError] = useState<string | null>(null);
    
      const onSearch = useCallback(() => {
        const published = useFlightKitStore.getState().searchRequest;
        if (!published) return;
    
        // Always abort the in-flight search. A slow first response landing after a
        // faster second one would show offers for a search already replaced.
        const controller = new AbortController();
    
        client
          .searchOffers(published.params, controller.signal)
          .then((response) => {
            const offers = mapTravelportOfferings(response);
    
            // A console is the only place a payload this size can be read whole.
            console.log('offers', offers);
    
            // Never put the catalog in the store. Persist searchId + offerRefs and
            // let the server re-resolve it from its cache.
          })
          .catch((cause) => {
            // An abort is not a failure — it is a newer search replacing this one.
            if (controller.signal.aborted) return;
    
            setError(cause instanceof FlightKitApiError ? cause.message : 'Search failed');
          });
      }, [client]);
    
      return (
        <>
          <FlightSearchForm onSearch={onSearch} />
          {error ? <p role="alert">{error}</p> : null}
          {/* Mount TaskProgress here while the request is in flight — see /task-progress. */}
        </>
      );
    }

FlightSearchForm — props

onSearch
(criteria) => void

Fires after the store is updated. Routing, analytics, your own flow.

onChange
(criteria) => void

Fires on every edit, for keeping a parent or a summary in step.

defaultValues
Partial<SearchCriteria>

Seeds the fields. Saved store defaults still fill anything left empty.

airports
Airport[]

The shortlist shown before anything is typed.

searchAirports
(query) => Promise<AirportSearchResult[]>

Replaces the lookup. The fk-ui client is the default.

variant
'hero' | 'inline' | 'stacked'

Padding and elevation. It deliberately does not scale type. Default stacked.

className
string

Merged onto the card.

FlightSearchSummary — props

criteria
SearchCriteria

Controlled value, if the parent would rather own the state.

defaultExpanded
boolean

Whether the form starts open. Default false.

formVariant
'hero' | 'inline' | 'stacked'

Visual treatment of the form it reveals.

onSearch
(criteria) => void

As above — shared with the form.

onChange
(criteria) => void

As above — shared with the form.

defaultValues
Partial<SearchCriteria>

As above — shared with the form.

searchAirports
(query) => Promise<AirportSearchResult[]>

Passed through to the form.

className
string

Merged onto the card.

Code samples

The same files whole, in the order a reader would paste them. Copy takes the block as it is, comments included.

app/globals.css — the whole file
/* once, in your global stylesheet */
@import '@sheriph/fk-ui/tailwind.css';
app/providers.tsx — mounted once, above every booking stage
'use client';

import type { ReactNode } from 'react';
import { FlightKitProvider } from '@sheriph/fk-ui/core';

export function Providers({ children, agencyId }: { children: ReactNode; agencyId: string }) {
  return (
    // A same-origin path: your route handler attaches the API key there, so it
    // never reaches the browser.
    <FlightKitProvider agencyId={agencyId} apiBase="/api/flight-kit">
      {children}
    </FlightKitProvider>
  );
}
app/search-stage.tsx — the form, the summary and the store
'use client';

import { selectSearchRequest, useFlightKitStore } from '@sheriph/fk-ui/core';
import { FlightSearchForm, FlightSearchSummary } from '@sheriph/fk-ui/search';

export function SearchStage() {
  // What the next stage will fetch with. The form writes it; the results read it.
  const request = useFlightKitStore(selectSearchRequest);

  return (
    <section className="space-y-6">
      <FlightSearchForm
        onSearch={(criteria) => {
          // Fires after the store is updated, so the handoff has happened.
          // Route to your results page here — the whole of it is step 5.
          console.log('published', criteria);
        }}
      />

      <FlightSearchSummary />

      {request ? <p>Request #{request.requestId}</p> : null}
    </section>
  );
}
app/landing-hero.tsx — publish, then route on submit
'use client';

import { useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { FlightSearchForm, type SearchCriteria } from '@sheriph/fk-ui/search';

/** Where the results composite is mounted. Pick one path and keep it. */
const RESULTS_PATH = '/search-result';

/**
 * `YYYY-MM-DD` in the visitor's own timezone.
 *
 * Never `toISOString().slice(0, 10)`: that is UTC, so a visitor east or west of
 * Greenwich would share a link that books the day before or after.
 */
function toWireDate(date: Date): string {
  const month = `${date.getMonth() + 1}`.padStart(2, '0');
  const day = `${date.getDate()}`.padStart(2, '0');
  return `${date.getFullYear()}-${month}-${day}`;
}

/**
 * A submitted search as a shareable query string.
 *
 * The params mirror what `POST /flights/search` receives, so the results page can
 * rebuild the request from the URL alone — which is what makes a link work on a
 * cold load, before any store state exists.
 */
function toQuery(criteria: SearchCriteria): string {
  const query = new URLSearchParams();

  if (criteria.from) query.set('origin', criteria.from.iataCode);
  if (criteria.to) query.set('destination', criteria.to.iataCode);
  if (criteria.dates.start) query.set('departureDate', toWireDate(criteria.dates.start));
  if (criteria.tripType === 'round-trip' && criteria.dates.end) {
    query.set('returnDate', toWireDate(criteria.dates.end));
  }

  query.set('adults', String(criteria.passengers.adults));
  query.set('children', String(criteria.passengers.children));
  query.set('infants', String(criteria.passengers.infants));
  query.set('cabin', criteria.cabin);

  return query.toString();
}

export function LandingHero() {
  const router = useRouter();

  return (
    <FlightSearchForm
      onSearch={useCallback(
        (criteria: SearchCriteria) => {
          // The form has already published to the store by now, so the results
          // page has everything it needs before it looks at the URL. The URL is
          // what makes the search shareable.
          router.push(`${RESULTS_PATH}?${toQuery(criteria)}`);
        },
        [router],
      )}
    />
  );
}
app/search-stage.tsx — the manual path: publishing, then fetching the offers yourself
'use client';

import { useCallback, useState } from 'react';
import { mapTravelportOfferings } from '@sheriph/flight-kit-shared/transformers';
import { FlightKitApiError, useFlightKit, useFlightKitStore } from '@sheriph/fk-ui/core';
import { FlightSearchForm } from '@sheriph/fk-ui/search';

/**
 * The other half of the handoff: the form publishes, YOU fetch.
 *
 * `onSearch` fires *after* the store is updated, so the store is the source of
 * truth for what to send — `requestId` included, which is what makes two
 * identical searches two searches rather than one.
 */
export function SearchStage() {
  const client = useFlightKit();
  const [error, setError] = useState<string | null>(null);

  const onSearch = useCallback(() => {
    const published = useFlightKitStore.getState().searchRequest;
    if (!published) return;

    // Always abort the in-flight search. A slow first response landing after a
    // faster second one would show offers for a search already replaced.
    const controller = new AbortController();

    client
      .searchOffers(published.params, controller.signal)
      .then((response) => {
        const offers = mapTravelportOfferings(response);

        // A console is the only place a payload this size can be read whole.
        console.log('offers', offers);

        // Never put the catalog in the store. Persist searchId + offerRefs and
        // let the server re-resolve it from its cache.
      })
      .catch((cause) => {
        // An abort is not a failure — it is a newer search replacing this one.
        if (controller.signal.aborted) return;

        setError(cause instanceof FlightKitApiError ? cause.message : 'Search failed');
      });
  }, [client]);

  return (
    <>
      <FlightSearchForm onSearch={onSearch} />
      {error ? <p role="alert">{error}</p> : null}
      {/* Mount TaskProgress here while the request is in flight — see /task-progress. */}
    </>
  );
}