From Flask Monolith to Multiplatform App: Migration Journey Part 2

learningpythonreactdocs

First Real API Integration from Next.js

*Follow-up to Journey Part 1

Table of Contents

  1. What we're building
  2. React Query hooks
  3. The Navbar with live notifications
  4. WorkoutCard component
  5. The workouts list page
  6. The workout detail page
  7. Backend changes
  8. The three bugs that blocked us
  9. What's still rough
  10. Files changed in this milestone

1. What we're building

Milestone 1 gave us a Next.js app with working Supabase Auth — you could register, confirm your email, and log in. But the /workouts page was a placeholder:

export default function WorkoutsPage() {
  return (
    <div>
      <h1>My Workouts</h1>
      <p>Workout list will be implemented in Part 2.</p>
    </div>
  );
}

Milestone 2 replaces that placeholder with real data from the Flask API. This is the first time the two halves of the architecture actually talk to each other through the browser:

Browser (Next.js on :3000)
  → Supabase JWT in Authorization header
    → Flask API on :5001
      → SQLite / PostgreSQL
        → JSON response
          → React Query cache
            → UI renders

The deliverables:

  • React Query hooks for workouts, profile, and notifications
  • Navbar component with active link highlighting and an unread-message badge
  • WorkoutCard component for the list view
  • Workouts list page with pagination and a "hide completed" filter
  • Workout detail page showing exercises and sets

2. React Query hooks

React Query (TanStack Query) is a server-state manager. Think of it as a smart cache layer between your components and the API. Instead of calling fetch() directly and managing loading/error/data states yourself, you declare what data you need and React Query handles fetching, caching, refetching, and deduplication.

Analogy: React Query is like a librarian. You say "I need the workouts for page 2." The librarian checks the shelf (cache). If the book is fresh, you get it instantly. If it's stale, the librarian fetches a new copy in the background while you read the old one.

We created three hook files in web/src/hooks/:

use-workouts.ts — the main data hooks

"use client";

import {
  useQuery,
  useMutation,
  useQueryClient,
} from "@tanstack/react-query";
import { api } from "@/lib/api";
import type {
  Workout,
  PaginatedResponse,
  ApiResponse,
} from "@/types";

export function useWorkouts(
  page: number = 1,
  hideDone: boolean = false
) {
  return useQuery({
    queryKey: ["workouts", page, hideDone],
    queryFn: () =>
      api.get<PaginatedResponse<Workout>>("/workouts", {
        page: String(page),
        hide_done: hideDone ? "1" : "0",
      }),
  });
}

Key concepts:

  • queryKey is the cache address. ["workouts", 1, false] and ["workouts", 2, false] are different cache entries. When page changes, React Query knows to fetch new data.

  • queryFn is what actually fetches the data. It uses our api.get() helper from lib/api.ts, which automatically attaches the Supabase JWT as a Bearer token.

  • The generic <PaginatedResponse<Workout>> gives us full TypeScript safety — data.data is Workout[] and data.meta is PaginationMeta.

For mutations (actions that change data), we use useMutation with cache invalidation:

export function useToggleDone() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (id: number) =>
      api.post<ApiResponse<Workout>>(
        `/workouts/${id}/toggle-done`
      ),
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey: ["workouts"],
      });
    },
  });
}

When toggleDone.mutate(42) is called, it POSTs to the API, then invalidates all queries whose key starts with "workouts". This triggers a background refetch, so the list and detail pages both update automatically.

Pattern name: optimistic cache invalidation. We don't update the cache manually — we just tell React Query "the workouts data is stale, re-fetch it." Simple and reliable.

use-profile.ts — current user

export function useProfile() {
  return useQuery({
    queryKey: ["profile"],
    queryFn: () =>
      api.get<ApiResponse<User>>("/auth/profile"),
  });
}

One query, one cache entry. Any component that calls useProfile() shares the same cached data.

use-notifications.ts — polling for messages

export function useNotifications(since: number = 0) {
  return useQuery({
    queryKey: ["notifications", since],
    queryFn: () =>
      api.get<ApiResponse<Notification[]>>(
        "/notifications",
        { since: String(since) },
      ),
    refetchInterval: 30_000,
  });
}

refetchInterval: 30_000 tells React Query to re-fetch every 30 seconds. This is how the message badge stays up to date without WebSockets.

The helper hook extracts the unread count:

export function useUnreadMessageCount() {
  const { data } = useNotifications();
  const notifications = data?.data ?? [];
  const unread = notifications.find(
    (n) => n.name === "unread_message_count"
  );
  if (!unread?.data) return 0;
  return typeof unread.data === "number"
    ? unread.data
    : 0;
}

The Flask API stores unread_message_count as a raw number in the notification's payload_json field (via json.dumps(count)), so the data field in the response is just a number, not an object.

Learn more: TanStack Query overview


3. The Navbar with live notifications

In Milestone 0+1, the navbar was inlined in the server component (app)/layout.tsx. For Milestone 2, we extracted it into a client component because it needs hooks (notifications polling, active link detection):

// web/src/components/layout/navbar.tsx
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUnreadMessageCount }
  from "@/hooks/use-notifications";

Active link highlighting

function NavLink({
  href,
  children,
}: {
  href: string;
  children: React.ReactNode;
}) {
  const pathname = usePathname();
  const active =
    pathname === href ||
    pathname.startsWith(href + "/");
  return (
    <Link
      href={href}
      className={
        active
          ? "font-medium text-gray-900"
          : "text-gray-600 hover:text-gray-900"
      }
    >
      {children}
    </Link>
  );
}

usePathname() is a Next.js hook that returns the current URL path. We check if it matches exactly (/ workouts) or starts with the href (/workouts/123). This way, the "Workouts" link stays highlighted when you're on a workout detail page.

Learn more: Next.js usePathname

Message badge

<NavLink href="/messages">
  Messages
  {unreadCount > 0 && (
    <span className="ml-1 inline-flex h-5 min-w-5
      items-center justify-center rounded-full
      bg-red-500 px-1.5 text-xs font-medium
      text-white">
      {unreadCount}
    </span>
  )}
</NavLink>

The red pill badge only renders when unreadCount > 0. Because useUnreadMessageCount() uses React Query with refetchInterval: 30_000, the badge updates every 30 seconds without any manual polling logic.

Layout integration

The (app)/layout.tsx server component became much simpler:

import Navbar from "@/components/layout/navbar";

export default async function AppLayout({ children }) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) redirect("/login");

  return (
    <div className="min-h-screen">
      <Navbar />
      <main className="mx-auto max-w-5xl px-4 py-6">
        {children}
      </main>
    </div>
  );
}

The layout still does the auth check server-side (fast, no flash of unauthenticated content), but the Navbar itself is a client component that manages its own state.

Pattern name: server/client component split. Auth checks happen server-side for security. Interactive UI (hooks, event handlers) lives in client components. This is the recommended Next.js App Router pattern.


4. WorkoutCard component

Each workout in the list is rendered by a reusable card:

// web/src/components/workout/workout-card.tsx
"use client";

import Link from "next/link";
import { format } from "date-fns";
import type { Workout } from "@/types";
import {
  useToggleDone,
  useDeleteWorkout,
} from "@/hooks/use-workouts";

export default function WorkoutCard(
  { workout }: { workout: Workout }
) {
  const toggleDone = useToggleDone();
  const deleteWorkout = useDeleteWorkout();
  const exerciseCount = workout.exercises?.length ?? 0;

  return (
    <div className="rounded-lg border border-gray-200
      bg-white p-4">
      {/* ... title, date, exercise count ... */}
      <button
        onClick={() => toggleDone.mutate(workout.id)}
        disabled={toggleDone.isPending}
        className={workout.is_done
          ? "bg-green-100 text-green-700"
          : "bg-gray-100 text-gray-600"}
      >
        {workout.is_done ? "Done" : "Mark done"}
      </button>
    </div>
  );
}

Design decisions:

  • date-fns for formatting — we use format(new Date(workout.timestamp), "MMM d, yyyy 'at' HH:mm") which gives "Apr 12, 2026 at 19:21". date-fns is tree-shakeable (you only import the functions you use), unlike moment.js.

  • isPending for button state — React Query's mutation hook provides isPending so we can disable buttons during the API call. No manual loading state needed.

  • Confirmation dialog for delete — a simple confirm() before deleting. Not pretty, but functional for now.

Learn more: date-fns format


5. The workouts list page

The placeholder is gone. The real page uses our hooks:

"use client";

import { useState } from "react";
import { useWorkouts } from "@/hooks/use-workouts";
import WorkoutCard
  from "@/components/workout/workout-card";

export default function WorkoutsPage() {
  const [page, setPage] = useState(1);
  const [hideDone, setHideDone] = useState(false);
  const { data, isLoading, error } =
    useWorkouts(page, hideDone);

  const workouts = data?.data ?? [];
  const meta = data?.meta;
  // ...
}

Three UI states:

  1. Loading — "Loading workouts..." text
  2. Error — red message with the actual error string (helpful during development)
  3. Empty — "No workouts yet" when the array is empty

Pagination uses the meta object from the Flask API:

{meta && (meta.has_prev || meta.has_next) && (
  <div className="flex justify-between">
    <button
      onClick={() => setPage((p) => p - 1)}
      disabled={!meta.has_prev}
    >
      Previous
    </button>
    <span>
      Page {meta.page} of
      {Math.ceil(meta.total / meta.per_page)}
    </span>
    <button
      onClick={() => setPage((p) => p + 1)}
      disabled={!meta.has_next}
    >
      Next
    </button>
  </div>
)}

When page changes via setPage, the queryKey changes from ["workouts", 1, false] to ["workouts", 2, false], which triggers a new fetch. React Query handles everything else — caching page 1 while fetching page 2, showing stale data while refreshing, etc.

The "Hide completed" checkbox resets to page 1 when toggled (because filtering changes the total count):

<input
  type="checkbox"
  checked={hideDone}
  onChange={(e) => {
    setHideDone(e.target.checked);
    setPage(1);
  }}
/>

6. The workout detail page

Clicking a workout title navigates to /workouts/[id]. In Next.js App Router, dynamic routes use bracket folders:

web/src/app/(app)/workouts/[id]/page.tsx

The [id] folder means Next.js extracts the URL segment as a parameter. In Next.js 16, params are delivered as a Promise:

export default function WorkoutDetailPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = use(params);
  const workoutId = Number(id);
  const { data, isLoading, error } =
    useWorkout(workoutId);
  // ...
}

Why use(params) instead of await params? This is a client component ("use client"), so we can't use await at the top level. React's use() hook unwraps the Promise synchronously during render.

The detail page shows each exercise with a sets table:

{workout.exercises.map((exercise) => (
  <div key={exercise.id}>
    <h3>
      {exercise.exercise_definition_title
        ?? `Exercise #${exercise.exercise_order}`}
    </h3>
    <table>
      <thead>
        <tr>
          <th>Set</th>
          <th>Progression</th>
          <th>Reps</th>
          <th>Duration</th>
        </tr>
      </thead>
      <tbody>
        {exercise.sets.map((set) => (
          <tr key={set.id}>
            <td>{set.set_order}</td>
            <td>{set.progression ?? "-"}</td>
            <td>{set.reps ?? "-"}</td>
            <td>{set.duration_formatted || "-"}</td>
          </tr>
        ))}
      </tbody>
    </table>
  </div>
))}

The Delete button redirects back to the list after success:

deleteWorkout.mutate(workoutId, {
  onSuccess: () => router.push("/workouts"),
});

Learn more: Next.js dynamic routes


7. Backend changes

Milestone 2 required three backend changes to support the new frontend:

7a. Exercise definition title in API response

The workout detail API returns exercises, but each exercise only had exercise_definition_id — not the name of the exercise. The detail page needs to show "Push-ups", not "Definition #7".

We added one field to Exercise.to_dict() in models.py:

data: dict[str, Any] = {
    "id": self.id,
    "exercise_order": self.exercise_order,
    "workout_id": self.workout_id,
    "exercise_definition_id":
        self.exercise_definition_id,
    "exercise_definition_title": (
        self.exercise_definition.title
        if self.exercise_definition
        else None
    ),
}

This uses the existing SQLAlchemy backref relationship — no extra query, no migration. SQLAlchemy lazy-loads the related ExerciseDefinition when we access .exercise_definition.title.

7b. ES256 JWT support (Supabase's new token format)

This was the biggest surprise. Supabase has migrated from HS256 (symmetric, shared secret) to ES256 (asymmetric, ECDSA public/private key pair) for their JWTs. Our _verify_supabase_jwt() only supported HS256 and was rejecting every token.

The fix: peek at the JWT header to detect the algorithm, then verify accordingly:

def _verify_supabase_jwt(token):
    header = jwt.get_unverified_header(token)
    alg = header.get("alg", "")

    if alg == "HS256":
        return _verify_hs256(token)
    elif alg.startswith("ES"):
        return _verify_es256(token)
    else:
        return None

For ES256, we use PyJWT's PyJWKClient to fetch the public key from Supabase's JWKS endpoint:

from jwt import PyJWKClient

_jwks_client: PyJWKClient | None = None

def _get_jwks_client() -> PyJWKClient | None:
    global _jwks_client
    if _jwks_client is not None:
        return _jwks_client
    supabase_url = current_app.config.get("SUPABASE_URL")
    jwks_url = (
        f"{supabase_url.rstrip('/')}"
        f"/auth/v1/.well-known/jwks.json"
    )
    _jwks_client = PyJWKClient(
        jwks_url, cache_keys=True
    )
    return _jwks_client

def _verify_es256(token):
    jwks_client = _get_jwks_client()
    signing_key = (
        jwks_client.get_signing_key_from_jwt(token)
    )
    return jwt.decode(
        token,
        signing_key.key,
        algorithms=["ES256"],
        audience="authenticated",
    )

JWKS (JSON Web Key Set) is a standard endpoint that publishes public keys. The backend fetches the public key matching the JWT's kid (key ID) header claim, then uses it to verify the signature. The key is cached in-process, so it's only fetched once.

Think of it like a notary's public seal — anyone can verify a document is authentic by checking against the published seal, but only the notary (Supabase) can create the signature.

This required installing the cryptography package:

uv pip install "PyJWT[crypto]"

And adding SUPABASE_URL to the backend .env:

SUPABASE_URL="https://sgvygdfcpasvfsaobwem.supabase.co"

Learn more: RFC 7517 — JSON Web Key, PyJWT JWKS docs

7c. New JWT payload structure

The old Supabase HS256 JWT had email_confirmed_at at the top level. The new ES256 JWT moved it to user_metadata.email_verified:

# Old HS256 payload:
{
  "sub": "uuid",
  "email": "user@example.com",
  "email_confirmed_at": "2026-04-11T...",
  "aud": "authenticated"
}

# New ES256 payload:
{
  "sub": "uuid",
  "email": "user@example.com",
  "aud": "authenticated",
  "user_metadata": {
    "email_verified": true
  },
  "app_metadata": {
    "provider": "email"
  },
  "amr": [{"method": "password", ...}]
}

We updated _get_or_create_user() to check multiple locations:

user_meta = payload.get("user_metadata") or {}
app_meta = payload.get("app_metadata") or {}

confirmed = (
    # Legacy HS256 format
    payload.get("email_confirmed_at") is not None
    # New ES256 format
    or user_meta.get("email_verified") is True
    or app_meta.get("email_verified") is True
    # OTP-based confirmation
    or any(
        f.get("method") == "otp"
        for f in payload.get("amr", [])
    )
)

This is backward-compatible — existing tests using the old email_confirmed_at field still pass (400/400).

7d. CORS configuration

Cross-origin requests from localhost:3000 to localhost:5001 require CORS headers. Flask-CORS 6.0 needed explicit allow_headers for the Authorization header to pass preflight:

CORS(
    app,
    resources={r"/api/*": {"origins": "*"}},
    allow_headers=["Content-Type", "Authorization"],
    methods=[
        "GET", "POST", "PUT", "DELETE", "OPTIONS"
    ],
)

8. The three bugs that blocked us

Milestone 2's code was straightforward. The bugs were all environmental — the kind you only hit when two systems talk to each other for the first time.

Bug 1: AirPlay Receiver stealing port 5000

Symptom: net::ERR_FAILED on every API request. CORS headers missing.

Root cause: macOS AirPlay Receiver listens on port 5000 by default. Flask thought it was running on 5000, but the browser was hitting Apple's AirTunes service instead. The giveaway was the response header:

Server: AirTunes/935.7.1

Fix: Run Flask on port 5001 and update NEXT_PUBLIC_API_URL in web/.env.local:

NEXT_PUBLIC_API_URL=http://localhost:5001/api/v1

Lesson: On macOS, always check what's actually listening on your port. curl -I http://localhost:5000 is a quick sanity check.

Bug 2: Supabase switched to ES256 JWTs

Symptom: 401 Unauthorized on every API request. Session exists in browser, token is present.

Root cause: The JWT started with eyJhbGciOiJFUzI1NiIs — that's ES256 (ECDSA), not HS256. Supabase migrated to asymmetric JWTs as part of their new API key format. Our backend was hardcoded to algorithms=["HS256"].

Fix: Detect algorithm from JWT header, verify with JWKS public key for ES256. Install PyJWT[crypto] for the cryptography dependency. See section 7b above.

Bug 3: email_confirmed_at missing from new JWT

Symptom: 403 Forbidden after fixing the 401. User auto-created successfully but confirmed=False.

Root cause: The new ES256 JWT payload moved email confirmation from email_confirmed_at (top-level) to user_metadata.email_verified (nested boolean).

Fix: Check multiple payload locations. See section 7c above.

Meta-lesson: When integrating with a managed service like Supabase, their token format can change between the time you write auth code and the time you test it. Always log the actual JWT payload during initial integration — don't assume the docs match reality.


9. What's still rough

Milestone 2 proved the data flow works end-to-end. But the UI is functional, not polished:

  • Duration column always shown — the workout detail page shows Set/Progression/Reps/Duration columns for every exercise, even when the exercise definition's counting_type is "reps" (no duration data). Needs the counting_type field in the API response.

  • No create/edit forms — you can view and delete workouts, but not create new ones from the Next.js UI yet. That's Milestone 4.

  • No mobile responsive menu — the nav links are hidden sm:flex, so they disappear on mobile with no hamburger menu.

  • Confirm dialog for delete — uses browser-native confirm(). Should be a proper modal component.

  • No loading skeletons — just text-based "Loading workouts..." instead of animated placeholders.

These are all solvable in later milestones. The important thing is the plumbing works.


10. Files changed in this milestone

New files

FilePurpose
web/src/hooks/use-workouts.tsReact Query hooks for workout CRUD
web/src/hooks/use-profile.tsHook for current user profile
web/src/hooks/use-notifications.tsNotification polling + unread count
web/src/components/layout/navbar.tsxClient-side navbar with badge
web/src/components/workout/workout-card.tsxWorkout list card component
web/src/app/(app)/workouts/[id]/page.tsxWorkout detail page

Modified files

FileWhat changed
web/src/app/(app)/workouts/page.tsxReplaced placeholder with real list
web/src/app/(app)/layout.tsxExtracted inline nav → Navbar component
web/src/lib/api.tsSession fallback logic
web/src/lib/supabase/client.tsSingleton pattern for client
web/src/types/index.tsAdded exercise_definition_title to Exercise
backend/project/api/auth_utils.pyES256 JWKS support + new payload structure
backend/project/__init__.pyExplicit CORS allow_headers
backend/project/models.pyexercise_definition_title in Exercise.to_dict()
backend/requirements.txtpyjwt[crypto] for ES256
backend/.envAdded SUPABASE_URL
web/.env.localChanged port 5000 → 5001

Dependencies added

PackageWhy
cryptography (Python)Required by PyJWT for ES256/ECDSA signature verification