Skip to content
Back to Blog
August 1, 2026

Structured Streaming: Rendering Flight Cards Mid-Response

How typed server streaming events let us render rich travel UI — flight cards, hotel options, and booking confirmations — before the AI finishes thinking.

Structured Streaming: Rendering Flight Cards Mid-Response
M

There is a moment in our chat interface that I find genuinely satisfying as an engineer. A traveler asks for flights to Barcelona. The AI starts responding with text, and then, mid-sentence, a fully interactive flight card materializes in the conversation. It has the airline, departure and arrival times, price, number of stops, and a "Book" button. The card appears before the AI has finished its text response. The traveler can start comparing options while the AI is still talking.

This works because of structured streaming. Instead of treating our server streaming pipeline as a text-only channel, we designed it to carry typed events with different payloads. Text is one type. Flight offers are another. Hotel options are another. Each type has a defined schema, and the client knows how to render each one the instant it arrives.

Why plain text streaming is not enough

Illustration for this section

Most AI chat interfaces stream plain text. Token by token, the message appears on screen like someone typing. It looks nice. But for a travel booking platform, text alone doesn't cut it.

When the AI finds six flight options, you could describe them in text. "The first option is a direct flight on March 20th departing at 8:15 AM, arriving at 4:30 PM, priced at $487..." Now do that six times. The traveler is reading a wall of text, trying to compare departure times and prices by scrolling up and down. It's the 38-website problem all over again, just crammed into a chat window.

Flight cards solve this by presenting structured data in a format designed for comparison. Times are aligned. Prices are prominent. Stop counts are visual. You can scan six options in a few seconds and spot the one you want.

But cards need structured data, not text. The client needs to know this is a flight offer, not a paragraph. It needs the departure time as a timestamp, not embedded in prose. It needs the price as a number, not "$487" somewhere in a sentence.

Designing event payloads

We defined a set of server streaming event types, each with its own payload schema. The key ones for travel UI:

Flight offer events carry an array of flight options. Each option includes origin, destination, departure and arrival times, carrier, number of stops, duration, cabin class, and price. The payload is JSON, typed, and validated before the server sends it.

Hotel offer events follow the same pattern for accommodation. Property name, location, star rating, room type, price per night, cancellation policy, and photos.

Booking confirmation events carry the confirmed booking details: confirmation number, itinerary summary, payment amount, and a link to the trip.

Status events are lightweight messages about what the agent is doing. "Searching for flights..." or "Comparing 8 hotel options..." These render as subtle indicators, not cards.

Content events are the plain text tokens of the AI's natural language response.

The important design decision was making these events independent. A flight offer event is self-contained. It doesn't depend on the content events around it. The client can render the card immediately on receipt, without waiting for surrounding text to provide context.

The rendering pipeline on the client

Supporting diagram

When an server streaming event arrives, the client does three things in sequence, and the whole sequence needs to complete in under 50 milliseconds for the experience to feel seamless.

First, it parses the event and identifies the type. This is a simple field check on the event payload.

Second, it routes the event to the appropriate renderer. Text events go to the message bubble component. Flight offers go to the flight card component. Hotel offers go to the hotel card component. We built a routing component that acts as a switch on event type.

Third, the renderer produces the UI. For cards, this means laying out the structured data into the card format. For text, it means appending tokens to the current message bubble.

The routing is important because events interleave. A typical response might look like this:

  1. Status: "Searching for flights to Barcelona..."
  2. Content: "I found several options for your trip to Barcelona. Here are the best..."
  3. Tool result: [array of 5 flight offers]
  4. Content: "The direct flight on the 20th gives you the most time..."
  5. Completion: [session state, final message ID]

The client renders each event as it arrives. By the time event 3 arrives, the traveler already sees the beginning of the AI's text. The flight cards appear inline, right where the AI is discussing them. Then more text continues below the cards.

Progressive enhancement during streaming

Cards don't always arrive complete in a single event. Sometimes the AI agent searches one data source, sends initial results, then searches another and sends updated results. We handle this with a progressive enhancement pattern.

When the first flight offers arrive, we render them immediately. If more offers arrive in a subsequent event (because the agent checked an additional source), we append them to the existing card group. The card component is designed to handle additions smoothly, with new options sliding in rather than the whole card rebuilding.

We also use skeleton states. When a status event says "Searching for flights..." the client renders a card skeleton. It's a placeholder with the right dimensions and layout, but no data yet. When the actual flight data arrives, it fills in the skeleton. This prevents layout shifts that would jar the user.

Handling malformed or out-of-order events

Streams over real networks are not perfectly ordered. We've seen events arrive out of sequence, get duplicated by reconnection attempts, or arrive partially corrupted.

For ordering, each event carries a sequence number. The client maintains a small reorder buffer. If event 7 arrives before event 6, we hold event 7 briefly and wait for 6. If 6 doesn't arrive within a short window, we render 7 anyway and handle 6 as a late arrival if it shows up.

For duplicates, we track event IDs. If we see the same ID twice, the second one gets dropped.

For corruption, we validate the JSON payload against the expected schema for that event type. If validation fails, we drop the event and log it. The surrounding text usually provides enough context that a dropped card event is recoverable. The traveler might not see one flight option, but the AI's text description still references it, and a follow-up query can retrieve it.

Cards are ephemeral, and that is intentional

One design decision that surprises people: we don't persist cards to the database. The chat message record in our database stores only the text content. Cards, flight offers, hotel options -- those are ephemeral. They exist during the conversation session but aren't saved.

This was a deliberate choice. Travel offers are time-sensitive. A flight price from three hours ago is probably wrong. A hotel rate from yesterday might not be available. Persisting stale offer data would create a misleading history. When a traveler scrolls back through their conversation, they see the text of what was discussed. If they want current offers, they ask again and get live data.

The trade-off is that reloading the page mid-conversation loses the cards. We mitigate this by keeping session state that lets the AI reconstruct context quickly. The traveler can say "show me those flights again" and get a fresh set of results based on the same criteria.

Build your own structured streaming

If you're implementing something similar, here are the payload patterns that worked for us.

Keep event types flat, not nested. Each event type should be independently renderable. Don't make flight cards depend on a preceding context event to know what search they belong to.

Use typed discriminators. Every event has a `type` field. The client switches on this field. Add new event types without breaking existing ones.

Include enough metadata for independent rendering. A flight card should contain everything the card component needs. Don't make the client look up additional data.

Design for the rendering budget. If your target is 50ms from event receipt to pixels on screen, measure it. Complex card layouts with images and animations can blow through that budget easily. Keep the initial render simple, then enhance after paint.

Accept that ephemeral data is OK. Not everything needs to be persisted. If the data has a short shelf life, let it live in the stream and die with the session. Your database will thank you.


Nowah is an AI travel agent that searches and books real flights and hotels through conversation — no filters, no thirty open tabs. Plan your next trip.

Share this article

Ready to Plan with Nowah?

Bring the idea. Nowah will help turn it into a trip.

Try Nowah