Share

Building the Shopping List Chat UI: Auth, Stripe, and a Layout That Wouldn't Cooperate

Building the Shopping List Chat UI: Auth, Stripe, and a Layout That Wouldn't Cooperate
Photo by Rahul Mishra / Unsplash

By RJ Militante · May 2026

This is part two of the shoppinglist-chat build. Part one covered rearchitecting the backend — moving off Azure, replacing local dependencies with hosted infrastructure, and fixing a hallucination bug caused by a RAG skip threshold that was too aggressive. By the end of that post, the FastAPI backend was stable: Qdrant Cloud for vector search, DigitalOcean Serverless Inference for the LLM and embeddings, SQLite for chat history, streaming working end to end.d

This post picks up from there. With the backend solid, the work shifted to the frontend — rebuilding it properly in Next.js, fixing a series of layout issues that took longer than expected, then adding Clerk for authentication and Stripe for payments. Three distinct phases, one after the other: get the infrastructure right, get the UI right, then layer in auth and payments on top of both.

The backend for shoppinglist-chat was running cleanly — FastAPI, RAG over a product catalog, token streaming. The frontend needed to catch up. This post covers how we built the Next.js UI: what broke, what took longer than it should have, and how auth and Stripe got added at the end.


Starting point

The original UI was a single app/static/index.html file served by FastAPI. It worked for development, but it wasn't going to scale. The plan: move to a proper Next.js app, deploy it to Vercel, and keep the backend on the VPS behind nginx. Two separate deployments, independent change cadences.

The initial Next.js app was straightforward — a ChatPanel for streaming chat and a SearchPanel for direct vector search, both calling the FastAPI backend through a proxy so only port 3000 needed to be exposed:

// next.config.ts — initial approach
rewrites: async () => [
  { source: "/api/:path*", destination: "http://localhost:8000/:path*" },
],

This seemed fine until we tried to stream.


The streaming problem

Next.js rewrites buffer the response before forwarding it. For a normal JSON endpoint that's invisible, but for a streaming chat response it means the entire reply arrives at once when the model finishes — no incremental rendering, no typing feel.

The fix: replace the rewrite with a proper App Router route handler that pipes the upstream stream directly:

// app/api/chat/[sessionId]/route.ts
export async function POST(req: Request, { params }: { params: { sessionId: string } }) {
  const body = await req.json();
  const upstream = await fetch(`${API}/chat/${params.sessionId}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  return new Response(upstream.body, {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  });
}

Route handlers forward the ReadableStream without buffering. Streaming worked immediately after switching.

The same pattern got applied to /history/:sessionId and /search — everything proxied through route handlers rather than config-level rewrites. The next.config.ts rewrite block was removed entirely.


The bubble problem

This took longer than the streaming fix. The symptom: user message bubbles overflowing the viewport on narrow screens. The cause: Flexbox's default behavior when max-w-[75%] applies to the bubble container rather than the bubble content.

The naive implementation had the bubble as the flex child with self-end:

// original — overflows because flex child can still exceed the container
<div className="self-end max-w-[75%]">
  <div className="bg-blue-500 text-white ...">
    {content}
  </div>
</div>

max-w-[75%] constrains the outer div, but if the flex container itself is wider than expected — due to the message list's layout — that 75% resolves to more than the visible area.

The fix was to stop relying on Tailwind's percentage classes for overflow prevention and set the constraint with an inline style on the content element directly, inside a wrapper that handles alignment:

// fixed — wrapper handles justification, inline style caps content width
<div className="flex justify-end">
  <div
    className="bg-blue-500 text-white rounded-2xl rounded-tr-sm px-4 py-2.5 text-sm leading-relaxed break-words"
    style={{ maxWidth: "65%", wordBreak: "break-word" }}
  >
    {content}
  </div>
</div>

Separating "alignment" from "width constraint" is the reliable pattern for chat bubbles in Flexbox. The outer div owns the position, the inner div owns the max width. Same approach applied to the assistant bubble on the other side.

Alignment had a related issue: align-self values weren't sticking reliably across different message list heights. The final fix was switching the message list from flex-col to CSS grid, which makes align-items and per-row sizing explicit rather than dependent on flex context:

<div className="grid grid-cols-1 gap-3 ...">
  {messages.map((m, i) => <ChatBubble key={i} {...m} />)}
</div>

The layout rebuild also cleaned up the visual structure: the header and input bar sit on white, the message area sits on gray-50, and the input grew from a small rounded-xl to a rounded-2xl with a focus ring. Small things, but they add up to something that looks intentional.


Adding Clerk auth

With the layout stable, auth was next. Clerk was already in the plan — the backend was using a random localStorage session ID as a stopgap, with a note to replace it with a real user ID once auth was in place.

Installation and setup is minimal. ClerkProvider wraps the app in layout.tsx. Two catch-all route files handle the hosted sign-in and sign-up pages:

// app/sign-in/[[...sign-in]]/page.tsx
import { SignIn } from "@clerk/nextjs";
export default function Page() {
  return <SignIn />;
}

Route protection goes in middleware.ts rather than per-page:

// middleware.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";

const isPublic = createRouteMatcher(["/sign-in(.*)", "/sign-up(.*)"]);

export default clerkMiddleware((auth, req) => {
  if (!isPublic(req)) auth().protect();
});

The session ID change was a one-liner in ChatPanel.tsx. Before: a random string from localStorage. After: the Clerk user ID pulled from useAuth():

const { userId } = useAuth();
// passed directly to POST /api/chat/:sessionId

Chat history is now tied to a real account. The random session approach was always temporary — this closes that gap.

A UserButton in the header handles sign-out. The whole integration took one commit.


Adding Stripe checkout

The product search panel was already showing items from the catalog. The next step was letting users add items to a cart and check out.

The cart state lives in the root page.tsx — a CartItem[] array that both the SearchPanel (adds items) and a new CartDrawer (displays and manages items) read and write through callbacks. Keeping the cart at the top level means both panels stay in sync without a state management library.

// lib/types.ts
export interface CartItem {
  product_id: string;
  name: string;
  price_cents: number;
  quantity: number;
}

CartDrawer is a slide-in panel: backdrop overlay, item list with quantity controls, total, and a checkout button. Quantity adjustments and removes go through the parent callbacks. The drawer is visually simple — right-anchored, w-80, white background, no animation library:

<div className="fixed right-0 top-0 h-full w-80 bg-white shadow-xl z-50 flex flex-col">

Checkout hits /api/checkout, which proxies to a /checkout endpoint on the FastAPI backend. The backend creates a Stripe Checkout Session and returns the URL. The frontend redirects:

// lib/api.ts
export async function createCheckoutSession(items: CartItem[]): Promise<string> {
  const res = await fetch("/api/checkout", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ items }),
  });
  const data = await res.json();
  if (!res.ok) throw new Error(data.detail ?? "checkout failed");
  return data.url;
}
async function checkout() {
  const url = await createCheckoutSession(items);
  window.location.href = url;  // Stripe-hosted checkout page
}

Success and cancel routes (/checkout/success, /checkout/cancel) are simple pages that confirm the result and link back to the app. Stripe handles payment collection entirely — no card inputs in the UI.


What the UI looks like now

app/
  layout.tsx                      — ClerkProvider, font
  page.tsx                        — root, cart state, tab switcher
  sign-in/[[...sign-in]]/page.tsx
  sign-up/[[...sign-up]]/page.tsx
  checkout/
    success/page.tsx
    cancel/page.tsx
  api/
    chat/[sessionId]/route.ts     — streams FastAPI response
    history/[sessionId]/route.ts
    search/route.ts
    checkout/route.ts             — proxies Stripe session creation
components/
  ChatPanel.tsx                   — streaming chat, Clerk user ID as session
  ChatBubble.tsx                  — markdown rendering, source tags, streaming cursor
  SearchPanel.tsx                 — vector search results, add-to-cart
  CartDrawer.tsx                  — cart state, Stripe redirect

The two repos deploy independently: FastAPI on the VPS behind nginx, Next.js on Vercel. The frontend never talks directly to the FastAPI port — all requests go through the Next.js route handlers.


What's still ahead

  • PostgreSQL migration. SQLite works for a single deployment, but multi-user history needs a real database. Supabase is the plan — Postgres and a hosted connection pool, no additional infrastructure.
  • Webhook handling. The Stripe checkout flow creates sessions but doesn't yet process checkout.session.completed events on the backend. Fulfillment logic — writing an order record, unlocking features for paid users — needs the webhook.
  • Per-user message limits. The free tier should have a daily cap. That requires knowing which users have active subscriptions, which comes after the webhook is in place.
  • Real product data. The catalog is still 90 synthetic medical supply items. Production needs real SKUs with real prices.

The actual insight

The streaming bug and the bubble overflow bug had the same root cause: using a higher-level abstraction (rewrites, Flexbox percentage widths) that works until the edge case hits, then spending time debugging behavior that was never going to be reliable. Route handlers and explicit inline constraints are more code, but they do exactly what they say.

Auth and payments were the straightforward part. Clerk and Stripe are well-documented, the integration surface is small, and the hard parts — session management, payment processing, PCI compliance — are handled by the provider.


The frontend repo is at github.com/rjthegreatxx/shoppinglist-chat-ui. The FastAPI backend is at github.com/rjthegreatxx/shoppinglist-chat.

Subscribe to AGNX Systems

Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe