From Flask Monolith to Multiplatform App: Migration Journey Part 2
First Real API Integration from Next.js
*Follow-up to Journey Part 1
Table of Contents
- What we're building
- React Query hooks
- The Navbar with live notifications
- WorkoutCard component
- The workouts list page
- The workout detail page
- Backend changes
- The three bugs that blocked us
- What's still rough
- 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:
-
queryKeyis the cache address.["workouts", 1, false]and["workouts", 2, false]are different cache entries. Whenpagechanges, React Query knows to fetch new data. -
queryFnis what actually fetches the data. It uses ourapi.get()helper fromlib/api.ts, which automatically attaches the Supabase JWT as a Bearer token. -
The generic
<PaginatedResponse<Workout>>gives us full TypeScript safety —data.dataisWorkout[]anddata.metaisPaginationMeta.
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
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-fnsfor formatting — we useformat(new Date(workout.timestamp), "MMM d, yyyy 'at' HH:mm")which gives "Apr 12, 2026 at 19:21".date-fnsis tree-shakeable (you only import the functions you use), unlike moment.js. -
isPendingfor button state — React Query's mutation hook providesisPendingso 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:
- Loading — "Loading workouts..." text
- Error — red message with the actual error string (helpful during development)
- 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 ofawait params? This is a client component ("use client"), so we can't useawaitat the top level. React'suse()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:5000is 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_typeis"reps"(no duration data). Needs thecounting_typefield 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
| File | Purpose |
|---|---|
web/src/hooks/use-workouts.ts | React Query hooks for workout CRUD |
web/src/hooks/use-profile.ts | Hook for current user profile |
web/src/hooks/use-notifications.ts | Notification polling + unread count |
web/src/components/layout/navbar.tsx | Client-side navbar with badge |
web/src/components/workout/workout-card.tsx | Workout list card component |
web/src/app/(app)/workouts/[id]/page.tsx | Workout detail page |
Modified files
| File | What changed |
|---|---|
web/src/app/(app)/workouts/page.tsx | Replaced placeholder with real list |
web/src/app/(app)/layout.tsx | Extracted inline nav → Navbar component |
web/src/lib/api.ts | Session fallback logic |
web/src/lib/supabase/client.ts | Singleton pattern for client |
web/src/types/index.ts | Added exercise_definition_title to Exercise |
backend/project/api/auth_utils.py | ES256 JWKS support + new payload structure |
backend/project/__init__.py | Explicit CORS allow_headers |
backend/project/models.py | exercise_definition_title in Exercise.to_dict() |
backend/requirements.txt | pyjwt[crypto] for ES256 |
backend/.env | Added SUPABASE_URL |
web/.env.local | Changed port 5000 → 5001 |
Dependencies added
| Package | Why |
|---|---|
cryptography (Python) | Required by PyJWT for ES256/ECDSA signature verification |