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.
Import the stylesheet once
One line in your global stylesheet. It pulls in Tailwind, the fk tokens, the
fk-*utility vocabulary, and the@sourcedirective 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';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.
agencyIdis required;apiBasedefaults 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> ); }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 aNEXT_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.Mount the composites
FlightSearchFormcollects and publishes;FlightSearchSummaryreads 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.onSearchfires after the store update, so it is safe to route or log on it.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.tsso 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], )} /> ); }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
onSearchfires the criteria are already in the store, so your handler reads them back, callsclient.searchOffersand maps the response withmapTravelportOfferings. 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 holdssearchIdand 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.
/* once, in your global stylesheet */
@import '@sheriph/fk-ui/tailwind.css';'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>
);
}'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>
);
}'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],
)}
/>
);
}'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. */}
</>
);
}