Build a Python AI Workout Planner
Build a React and FastAPI app that generates and saves AI workout plans.
Introduction
30 Second Summary
A workout plan can look useful at first glance while ignoring the equipment or time someone actually has. Once the page refreshes, that plan can disappear before the next session.
In this project, you will build a local AI workout planner where a React form sends preferences to a Python FastAPI backend. OpenAI produces a schema-validated plan through Pydantic before Supabase stores it in PostgreSQL.
What You'll Build
You'll enter your workout preferences and see a personalized session appear as warmup steps, exercise cards, a cooldown, a safety note, and a saved-plan ID.
By the end of this project, you'll have:
- Personalized workout cards that change when you submit a different goal, experience level, equipment list, or session duration.
- Validated API responses with a consistent title, warmup, exercise list, cooldown, and safety note.
- Durable saved plans with an ID shown in the browser and a matching database row. Your service credentials remain inside the Python backend.
- Secret Mission: Add a recent-plans view that loads the five newest saved workouts from a new FastAPI endpoint.
Are there any prerequisites?
Basic JavaScript or TypeScript familiarity helps with the frontend. The guide covers Python installation plus the complete project setup.
Before We Start
Your workout planner needs a clear audience before you build it. This checkpoint helps you define who it serves and how their goal, experience, equipment, and available time shape the workout.
Set Up the Python and React Workspace
Your planner needs a Python runtime for the FastAPI backend. It also needs Node.js to run the React interface through Vite.
This step gives you a reproducible Windows workspace in Visual Studio Code. A consistent starting point keeps runtime issues separate from the workout features you build next.
In this step, get ready to:
- Install Python 3.14.7 from the official installer.
- Prepare the backend virtual environment with pinned dependencies.
- Scaffold the React TypeScript frontend with create-vite 9.2.1.
Verify Python and Node.js
Vite requires Node.js 20.19+ or 22.12+. The backend uses Python 3.14.7 so everyone installs the same dependency versions.
- Press the Windows key to open Windows search.
- Type Command Prompt into the search field.
- Press Enter to open Command Prompt.
- Check both runtime versions by running these commands:
python --version
node --version
What do these commands check?
The first command asks Windows which Python installation responds to python. The second command reports the installed Node.js version.
Choose the Python result that matches your terminal output.
✔️ Python is current
You should see Python 3.14.7. Your Python runtime is ready for the backend.
ⓧ I see an older Python version
An older runtime can install different dependency builds. Update Python before creating the virtual environment.
- Open the official Python 3.14.7 release page.
- Download the recommended Windows installer for Python 3.14.7.
- Run the downloaded installer.
- Complete the installer with its recommended Windows settings.
- Close Command Prompt.
- Open a new Command Prompt window through Windows search.
- Verify the updated Python version by running:
python --version
What should I see?
You should now see Python 3.14.7. A new Command Prompt session lets Windows use the updated installation.
Still seeing the older version?
Reopen the Python installer to confirm Python 3.14.7 completed successfully.
Use Help me make Python 3.14.7 respond to the python command on Windows. Share only non-secret terminal output with the NextWork community if you need another set of eyes.
ⓧ Python is not found
Windows cannot find a Python installation yet. The official installer adds the runtime needed for this project.
- Open the official Python 3.14.7 release page.
- Download the recommended Windows installer for Python 3.14.7.
- Run the downloaded installer.
- Complete the installer with its recommended Windows settings.
- Close Command Prompt.
- Open a new Command Prompt window through Windows search.
- Confirm that Python is now available by running:
python --version
What should I see?
You should see Python 3.14.7. That output confirms Windows can find the new installation.
Python still unavailable?
Close every Command Prompt window after installation. Open a fresh window so it reads the updated Windows path.
Use Help me troubleshoot why the python command is unavailable after installing Python 3.14.7 on Windows. Share only non-secret details with the NextWork community if the issue continues.
Now choose the Node.js result that matches your terminal output.
✔️ Node meets Vite's requirement
Your version is Node.js 20.19+ or 22.12+. Node.js is ready to scaffold the frontend.
ⓧ I see an older Node version
The installed version is below Vite's supported range. Updating it prevents the scaffold from failing later.
- Open the official Node.js download page.
- Download a supported LTS Windows installer.
- Run the Windows installer.
- Complete the upgrade with the recommended settings.
- Close Command Prompt.
- Open a new Command Prompt window through Windows search.
- Check the upgraded Node.js version by running:
node --version
What should I see?
The reported version should meet Node.js 20.19+ or 22.12+. The updated runtime can now support the pinned Vite scaffold.
Still seeing the old Node version?
Confirm the installer completed before reopening Command Prompt. An older terminal session can keep using the previous Windows path.
Use Help me update the Node.js version used by Command Prompt on Windows. Share the non-secret version output with the NextWork community if you remain stuck.
ⓧ Node is not found
Windows cannot find Node.js in the current session. Install a supported LTS release before creating the frontend.
- Open the official Node.js download page.
- Download a supported LTS Windows installer.
- Run the Windows installer.
- Complete the installation with the recommended settings.
- Close Command Prompt.
- Open a new Command Prompt window through Windows search.
- Confirm that Node.js is available by running:
node --version
What should I see?
You should see a Node.js version that meets 20.19+ or 22.12+. That output confirms Command Prompt can find Node.js.
Node still unavailable?
Open a fresh Command Prompt window after the installer finishes. The new session reads the updated Windows path.
Use Help me troubleshoot why the node command is unavailable on Windows. Share only non-secret installation details with the NextWork community if needed.
That is the first foundation in place. Both runtimes can now support the workspace you create next.
Build the backend workspace
A virtual environment keeps the backend packages inside this project. The environment variables file keeps service credentials outside the committed source files.
- Return to Command Prompt.
- Create the workspace on your Desktop by running these commands:
cd /d %USERPROFILE%\Desktop
mkdir ai-workout-planner\backend
What do these commands create?
The first command moves Command Prompt to your Desktop. The second command creates ai-workout-planner with a backend folder inside it.
- Open File Explorer through Windows search.
- Select your Desktop in File Explorer.
- Confirm that ai-workout-planner contains the backend folder.
Cannot find the workspace?
Check that Command Prompt showed your Desktop path before the folder command ran. Run the two commands again if the folder was created elsewhere.
Use Help me find the ai-workout-planner folder created from Command Prompt. Share the visible path with the NextWork community if you need more help.
- Press the Windows key to open Windows search.
- Type Visual Studio Code into the search field.
- Press Enter to open Visual Studio Code.
- Drag the ai-workout-planner folder from File Explorer into the Visual Studio Code window.
- Select ai-workout-planner in the Explorer sidebar.
- Create .gitignore with the new-file control.
- Protect local dependencies from source control by pasting this content:
node_modules
dist
backend/.venv
backend/.env
__pycache__
*.pyc
What does this file protect?
The ignored paths cover generated frontend packages plus local Python files. The backend/.env rule prevents service credentials from entering source control.
- Save .gitignore.
- Confirm that .gitignore appears beside the backend folder in Explorer.
File created in the wrong folder?
Move .gitignore beside backend. The file should sit directly inside ai-workout-planner.
Use Help me place the project .gitignore in the correct VS Code folder..
- Select the backend folder in Explorer.
- Create requirements.txt with the new-file control.
- Pin the backend dependencies by pasting this content:
fastapi[standard]==0.141.1
openai==3.13.0
pydantic==2.13.5
python-dotenv==1.2.3
supabase==2.31.0
Why pin every dependency?
Each == entry selects one tested package version. Matching versions make your environment easier to reproduce.
- Save backend/requirements.txt.
- Confirm that requirements.txt appears inside backend in Explorer.
Dependency file looks different?
Check every package name against the reference block. Check each version number before installing.
Use Help me compare my requirements.txt with the pinned backend dependency list..
- Keep the backend folder selected in Explorer.
- Create .env.example with the new-file control.
- Document the required backend settings by pasting this content:
OPENAI_API_KEY=your-api-key-here
SUPABASE_URL=your-supabase-url-here
SUPABASE_SECRET_KEY=your-supabase-secret-key-here
Why use an example environment file?
The example file records which settings the backend needs. Its placeholder values are safe to keep with the project.
The local .env file receives real values in later steps. Your .gitignore keeps that local file out of source control.
- Save backend/.env.example.
- Copy backend/.env.example in the Explorer sidebar.
- Paste the copied file into backend.
- Rename the copy to .env.
- Leave every placeholder unchanged.
- Confirm that both .env.example and .env appear inside backend.
Do the environment files look identical?
The two files should contain the same three placeholder lines for now. Do not add a real API key or secret during this step.
Use Help me create a local .env from .env.example without exposing credentials..
- Select the backend folder in the Explorer sidebar.
- Start an integrated Command Prompt terminal for backend.
- Create the isolated Python environment by running:
python -m venv .venv
What does this command create?
Python creates a .venv directory inside backend. Packages installed after activation stay isolated from other Python projects.
- Expand backend in Explorer.
- Confirm that the .venv folder now exists.
Virtual environment missing?
Check that the terminal path ends with backend. Confirm that python --version still reports Python 3.14.7.
Use Help me troubleshoot Python virtual environment creation in backend. Share the non-secret terminal output with the NextWork community if needed.
- Activate the virtual environment in Command Prompt by running:
.venv\Scripts\activate.bat
What does activation change?
Activation places the virtual environment's Python commands first for this terminal session. Your prompt should now begin with (.venv).
- Confirm that (.venv) appears at the start of the terminal prompt.
Activation command failed?
Confirm that this terminal is Command Prompt. Confirm that .venv exists directly inside backend.
Use Help me activate backend/.venv from Windows Command Prompt..
- Install the pinned backend packages into the active environment by running:
pip install -r requirements.txt
What does this installation use?
Pip reads every pinned entry from requirements.txt. The active virtual environment receives the backend libraries.
- Wait for the package installation to finish.
- Confirm that the terminal returns to the (.venv) prompt without a dependency installation error.
Package installation failed?
Confirm that requirements.txt is inside the current backend folder. Check that the terminal prompt still starts with (.venv).
Use Help me diagnose my pinned Python dependency installation error. Share the non-secret error output with the NextWork community if the install remains blocked.
Scaffold the React frontend
The frontend scaffold supplies a runnable TypeScript starting point. The pinned create-vite version generates the project structure expected by the later steps.
- Select ai-workout-planner in the Explorer sidebar.
- Start a new integrated Command Prompt terminal for ai-workout-planner.
- Approve the download if npm asks permission to fetch the pinned scaffolder.
- Create the React TypeScript starter by running:
npm create vite@9.2.1 frontend -- --template react-ts
What does this scaffold create?
Create-vite 9.2.1 creates the frontend directory from its React TypeScript template. The generated project pins React 19.2.8 plus TypeScript 6.0.2.
- Expand frontend in Explorer.
- Confirm that package.json appears inside frontend.
- Confirm that vite.config.ts appears inside frontend.
Frontend folder missing?
Check that the terminal started inside ai-workout-planner. Confirm that the command uses the react-ts template.
Use Help me troubleshoot the pinned create-vite React TypeScript scaffold. Share the non-secret output with the NextWork community if needed.
- Select the new frontend folder in Explorer.
- Start an integrated Command Prompt terminal for frontend.
- Install the generated frontend dependencies by running:
npm install
What does npm install?
Npm reads frontend/package.json and downloads its dependencies into node_modules. The generated lock file records the resolved dependency tree.
- Wait for npm to finish installing the packages.
- Confirm that the terminal returns to the prompt without an installation error.
Npm installation failed?
Confirm that the terminal path ends with frontend. Check that Node.js still meets Vite's required version range.
Use Help me diagnose an npm install error in the Vite frontend. Share only non-secret output with the NextWork community if the issue continues.
Before you start the development server, what address do you expect Vite to provide?
- Start the Vite development server by running:
npm run dev
What does this command start?
The dev script starts Vite's local development server. The terminal remains occupied while the server is running.
Keep this terminal open while viewing the frontend. Press Ctrl+C when you want to stop the server.
- Open your browser.
- Visit http://localhost:5173.
You should see the Vite React starter page. That page proves the generated frontend can compile and run locally.
Vite page not loading?
Keep the terminal running while the browser is open. Confirm that you started the server from the frontend folder.
Use Help me troubleshoot why the Vite starter is not loading on localhost. Share the non-secret output with the NextWork community if you remain blocked.
Great progress. Your React starter now runs from the same workspace as the prepared Python backend.
✔️ Awesome, I've got everything!
Save every open file. Keep the Vite terminal running for the next step.
ⓧ I'd like to double check the full code
Compare each project file with these references. Keep the paths exactly as shown.
node_modules
dist
backend/.venv
backend/.env
__pycache__
*.pyc
What should match here?
Your root .gitignore should contain these six lines in this order.
OPENAI_API_KEY=your-api-key-here
SUPABASE_URL=your-supabase-url-here
SUPABASE_SECRET_KEY=your-supabase-secret-key-here
What should match here?
Your example environment file should contain three placeholder settings. No real credentials belong in this reference file.
fastapi[standard]==0.141.1
openai==3.13.0
pydantic==2.13.5
python-dotenv==1.2.3
supabase==2.31.0
What should match here?
Your dependency file should contain these five pinned package lines. Each version must match exactly.
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"oxlint": "^1.81.0",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}
What should match here?
This generated package file should match the create-vite 9.2.1 React TypeScript scaffold.
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react()],
})
What should match here?
This Vite configuration should import the React plugin. It should register that plugin once.
Your workspace is ready. Next, you will give the Python backend its first validated workout endpoint.
Build the FastAPI Preview API
Your React starter is already running. Now it needs a FastAPI service that can answer a workout request.
The frontend needs a predictable workout contract before AI generation or database storage adds uncertainty. Pydantic will validate the fixed preview returned by this step.
In this step, get ready to:
- Define the Pydantic request and response models.
- Configure the FastAPI application for the React origin.
- Test the fixed preview through the interactive API documentation.
Create the API foundation and models
A browser origin identifies where a page is served. CORS lets the API accept requests from the React development origin.
- Create backend/main.py inside the existing backend folder with the VS Code Explorer sidebar.
- Add the application foundation by pasting this code into the empty file:
import os
from functools import lru_cache
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from openai import OpenAI
from pydantic import BaseModel
load_dotenv()
app = FastAPI(title="AI Workout Planner API")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=False,
allow_methods=["GET", "POST"],
allow_headers=["Content-Type"],
)
What does this foundation do?
- The imports provide the framework classes needed by this API.
- The load_dotenv() call loads local backend settings from the environment file.
- The FastAPI(...) call creates the application served by the development server.
- The CORSMiddleware configuration permits requests from http://localhost:5173.
- Save backend/main.py.
- Switch back to the Command Prompt terminal where backend/.venv is active.
The development server keeps this terminal busy while it runs. Leave the Vite server running in its existing terminal.
- Start the FastAPI development server from the backend folder by running:
fastapi dev main.py
- Load http://127.0.0.1:8000/docs in your browser.
You should see the interactive documentation page for the AI Workout Planner API. Your Python service is now live beside the React development server.
API documentation not loading?
Confirm that the command is running from the backend folder with backend/.venv active.
Check the terminal for an import traceback. A missing package means the dependency installation did not finish inside this virtual environment.
Help me troubleshoot the FastAPI development server.
The request models define what the browser sends. The response models define the validated JSON shape that comes back.
- Continue backend/main.py below the middleware configuration by adding these models:
class WorkoutRequest(BaseModel):
goal: str
experience_level: str
equipment: str
duration_minutes: int
class Exercise(BaseModel):
name: str
sets: int
reps: str
rest_seconds: int
instructions: str
class WorkoutPlan(BaseModel):
title: str
overview: str
warmup: list[str]
exercises: list[Exercise]
cooldown: list[str]
safety_note: str
class PreviewResponse(BaseModel):
plan: WorkoutPlan
How does the contract fit together?
- WorkoutRequest describes the four preferences accepted from the browser.
- Exercise gives every exercise the same typed fields.
- WorkoutPlan groups the session title with its warmup, exercises, cooldown, and safety note.
- PreviewResponse wraps the validated plan returned by the preview route.
- Save backend/main.py.
The backend terminal should remain active without a Python traceback. This confirms that FastAPI can import all four Pydantic models.
Seeing a model traceback?
Check that every field is indented inside its class. Python uses indentation to determine where each model ends.
Compare the class names carefully. WorkoutPlan must reference the existing Exercise model.
Help me fix my Pydantic models.
Add health and preview routes
A health route gives you a quick way to confirm that the server can answer requests. The preview route returns a complete plan with the same validated structure every time.
The API also needs a backend-only OpenAI client factory ready for a later route. Keeping this helper inside Python protects the service credential from browser code.
- Continue backend/main.py below PreviewResponse by adding the client helper and health route:
@lru_cache
def get_openai_client() -> OpenAI:
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise RuntimeError("OPENAI_API_KEY is missing from backend/.env")
return OpenAI(api_key=api_key)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
What do these helpers provide?
- The get_openai_client() helper reads the API key only inside the backend.
- The @lru_cache decorator lets the server reuse one client instance.
- The GET /health route returns a small status object without calling an external service.
- Save backend/main.py.
- Load http://127.0.0.1:8000/health in your browser.
You should see a JSON object whose status value is ok. The health route proves that your server can now answer a request.
Health route not appearing?
Confirm that the health route sits below the model classes in backend/main.py.
Save the file before checking the browser again. An unsaved route cannot be loaded by the server.
Help me debug the health route.
The preview route accepts WorkoutRequest data. Its response_model makes FastAPI validate the returned plan against PreviewResponse.
- Continue backend/main.py below the health route by adding the fixed preview route:
@app.post("/workouts/preview", response_model=PreviewResponse)
def preview_workout(preferences: WorkoutRequest) -> PreviewResponse:
plan = WorkoutPlan(
title="Starter Strength Session",
overview="A fixed preview that proves the API contract before personalization.",
warmup=["Five minutes of easy movement", "Bodyweight mobility flow"],
exercises=[
Exercise(name="Bodyweight Squat", sets=3, reps="10", rest_seconds=60, instructions="Use a comfortable range of motion and controlled tempo."),
Exercise(name="Incline Push-Up", sets=3, reps="8", rest_seconds=60, instructions="Choose an incline that lets every repetition stay controlled."),
Exercise(name="Glute Bridge", sets=3, reps="12", rest_seconds=45, instructions="Pause briefly at the top without overextending your back."),
],
cooldown=["Slow breathing", "Gentle lower-body stretching"],
safety_note="This is educational fitness content, not medical advice. Stop if you feel pain.",
)
return PreviewResponse(plan=plan)
What does the preview route prove?
- The route receives preferences through a validated WorkoutRequest object.
- The fixed WorkoutPlan includes every field that the frontend will render.
- The PreviewResponse wrapper gives the browser one stable response shape.
- The safety note keeps the preview framed as educational fitness content.
- Save backend/main.py.
Verify the preview contract
FastAPI builds its interactive documentation from the route models. This gives you a request editor plus a readable response without creating a separate test script.
- Return to the browser tab showing http://127.0.0.1:8000/docs.
- Expand the POST /workouts/preview operation.
- Enable request-body editing inside the expanded operation.
- Replace the generated request body with this sample:
{
"goal": "Build general strength",
"experience_level": "Beginner",
"equipment": "Dumbbells and a bench",
"duration_minutes": 35
}
What does this request describe?
- The goal field describes the outcome requested by the learner.
- The experience_level field captures the learner's current ability.
- The equipment field lists what the session can use.
- The duration_minutes field sets the available session time.
Before you send the request, think about whether the response will match every field in PreviewResponse.
- Send the request from the expanded operation.
You'll see a successful JSON response whose plan title is Starter Strength Session. The plan also contains warmup, exercises, cooldown, and safety_note fields.
That is the contract locked in. Your API can now turn a typed request into a complete validated workout response.
Preview request not working?
Check that the backend terminal is still running with the virtual environment active.
Compare the request field names with WorkoutRequest. Each name must use the same spelling.
Help me debug the preview request.
✔️ My code matches
Your backend/main.py file now matches the complete preview API.
ⓧ I want to double check
import os
from functools import lru_cache
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from openai import OpenAI
from pydantic import BaseModel
load_dotenv()
app = FastAPI(title="AI Workout Planner API")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=False,
allow_methods=["GET", "POST"],
allow_headers=["Content-Type"],
)
class WorkoutRequest(BaseModel):
goal: str
experience_level: str
equipment: str
duration_minutes: int
class Exercise(BaseModel):
name: str
sets: int
reps: str
rest_seconds: int
instructions: str
class WorkoutPlan(BaseModel):
title: str
overview: str
warmup: list[str]
exercises: list[Exercise]
cooldown: list[str]
safety_note: str
class PreviewResponse(BaseModel):
plan: WorkoutPlan
@lru_cache
def get_openai_client() -> OpenAI:
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise RuntimeError("OPENAI_API_KEY is missing from backend/.env")
return OpenAI(api_key=api_key)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/workouts/preview", response_model=PreviewResponse)
def preview_workout(preferences: WorkoutRequest) -> PreviewResponse:
plan = WorkoutPlan(
title="Starter Strength Session",
overview="A fixed preview that proves the API contract before personalization.",
warmup=["Five minutes of easy movement", "Bodyweight mobility flow"],
exercises=[
Exercise(name="Bodyweight Squat", sets=3, reps="10", rest_seconds=60, instructions="Use a comfortable range of motion and controlled tempo."),
Exercise(name="Incline Push-Up", sets=3, reps="8", rest_seconds=60, instructions="Choose an incline that lets every repetition stay controlled."),
Exercise(name="Glute Bridge", sets=3, reps="12", rest_seconds=45, instructions="Pause briefly at the top without overextending your back."),
],
cooldown=["Slow breathing", "Gentle lower-body stretching"],
safety_note="This is educational fitness content, not medical advice. Stop if you feel pain.",
)
return PreviewResponse(plan=plan)
Your validated preview API now gives the frontend a dependable workout shape. Next up, you'll replace the Vite starter with the workout form that consumes it.
Connect the React Workout Form
Your validated FastAPI preview is running. Now your React app needs a form that can send workout preferences to it.
A working browser request proves the complete path from the form to Python. You will render the returned workout before introducing AI-generated results.
In this step, get ready to:
- Build controlled fields for the workout preferences.
- Send the preferences to the preview endpoint.
- Render two contrasting submissions to reveal the fixed response.
Build the controlled request path
A controlled field keeps its current value in React state. Each change updates that state before the form sends all four preferences to the backend.
- In the VS Code Explorer sidebar, select frontend/src/App.tsx.
- Press Cmd+A on macOS or Ctrl+A on Windows.
- Delete the selected Vite starter code.
- Create the typed API contract at the top of App.tsx by pasting:
import { useState } from 'react'
import './App.css'
type Exercise = {
name: string
sets: number
reps: string
rest_seconds: number
instructions: string
}
type WorkoutPlan = {
title: string
overview: string
warmup: string[]
exercises: Exercise[]
cooldown: string[]
safety_note: string
}
type PreviewResponse = {
plan: WorkoutPlan
}
const API_URL = 'http://127.0.0.1:8000'
What do these types describe?
- The Exercise type describes every exercise card returned by Python.
- The WorkoutPlan type mirrors the validated workout structure from FastAPI.
- The PreviewResponse type matches the response object that wraps the plan.
- The API_URL constant gives every browser request one backend address.
- Start the App component directly below API_URL by pasting:
function App() {
const [goal, setGoal] = useState('Build general strength')
const [experienceLevel, setExperienceLevel] = useState('Beginner')
const [equipment, setEquipment] = useState('Dumbbells and a bench')
const [durationMinutes, setDurationMinutes] = useState(35)
const [plan, setPlan] = useState<WorkoutPlan | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function generateWorkout() {
setLoading(true)
setError('')
setPlan(null)
try {
const response = await fetch(`${API_URL}/workouts/preview`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
goal,
experience_level: experienceLevel,
equipment,
duration_minutes: durationMinutes,
}),
})
How does the request begin?
- The first four state values hold the preferences shown in the form.
- The plan state holds a successful response.
- The loading state prevents duplicate submissions while the request is running.
- The request converts React names into the snake-case field names expected by Python.
- Complete generateWorkout() directly below the previous chunk by pasting:
if (!response.ok) {
throw new Error((await response.text()) || 'Workout request failed')
}
const data: PreviewResponse = await response.json()
setPlan(data.plan)
} catch (requestError) {
setError(
requestError instanceof Error ? requestError.message : 'Workout request failed',
)
} finally {
setLoading(false)
}
}
How is the response handled?
- A failed response becomes an error that the page can display.
- A successful JSON response is checked against the TypeScript response shape.
- The finally block restores the button after either outcome.
- Begin the page layout directly below generateWorkout() by pasting:
return (
<main className="app-shell">
<section className="hero">
<p className="eyebrow">Python AI fitness project</p>
<h1>AI Workout Planner</h1>
<p>Generate a structured session from your goal, experience, equipment, and time.</p>
</section>
<section className="panel">
<form
className="planner-form"
onSubmit={(event) => {
event.preventDefault()
void generateWorkout()
}}
>
<label>
Goal
<input value={goal} onChange={(event) => setGoal(event.target.value)} />
</label>
<label>
Experience level
<select
value={experienceLevel}
onChange={(event) => setExperienceLevel(event.target.value)}
>
What makes these fields controlled?
- Each value reads from React state.
- Each onChange handler writes the learner's latest input back into state.
- The submit handler keeps the browser on the page while generateWorkout() runs.
- Add the remaining preference controls directly below the open select element by pasting:
<option>Beginner</option>
<option>Intermediate</option>
<option>Advanced</option>
</select>
</label>
<label>
Available equipment
<input
value={equipment}
onChange={(event) => setEquipment(event.target.value)}
/>
</label>
<label>
Session duration in minutes
<input
type="number"
min="20"
max="120"
value={durationMinutes}
onChange={(event) => setDurationMinutes(Number(event.target.value))}
/>
</label>
How are the preferences represented?
- The experience menu limits the field to three supported choices.
- The equipment field accepts a description of what is available.
- The duration field converts browser text into the number expected by FastAPI.
- Finish the form directly below the duration label by pasting:
<button disabled={loading} type="submit">
{loading ? 'Generating...' : 'Generate Workout'}
</button>
</form>
{error && <p className="error">{error}</p>}
</section>
{plan && (
<section className="panel workout-plan">
<h2>{plan.title}</h2>
<p>{plan.overview}</p>
<h3>Warmup</h3>
<ul>
{plan.warmup.map((item) => <li key={item}>{item}</li>)}
</ul>
<h3>Exercises</h3>
<div className="exercise-grid">
When does the workout appear?
- The button switches labels while the request is active.
- The error paragraph renders only when a request fails.
- The workout section renders only after plan contains the returned preview.
- The warmup array becomes one list item per activity.
- Complete the workout output directly below exercise-grid by pasting:
{plan.exercises.map((exercise, index) => (
<article className="exercise-card" key={`${exercise.name}-${index}`}>
<h4>{exercise.name}</h4>
<p>{exercise.sets} sets × {exercise.reps}</p>
<p>Rest: {exercise.rest_seconds} seconds</p>
<p>{exercise.instructions}</p>
</article>
))}
</div>
<h3>Cooldown</h3>
<ul>
{plan.cooldown.map((item) => <li key={item}>{item}</li>)}
</ul>
<p className="safety-note">{plan.safety_note}</p>
</section>
)}
</main>
)
}
export default App
How is the plan rendered?
- The exercise array becomes a set of cards with sets, repetitions, rest time, and instructions.
- The cooldown array becomes a second list.
- The safety note remains visible at the end of every returned workout.
- Save frontend/src/App.tsx.
- Return to the Vite page running at http://localhost:5173.
You should see the AI Workout Planner heading with four preference fields. You should also see the Generate Workout button.
Do you still see the Vite starter?
Confirm that you edited frontend/src/App.tsx. Check that the Vite terminal from earlier is still running.
A TypeScript error usually points to a missing brace or tag between two pasted chunks. Help me compare my App.tsx chunks.
✔️ Awesome, I've got everything!
Your form is ready. Double-check that frontend/src/App.tsx is saved before testing the request.
ⓧ I'd like to double check the full code
import { useState } from 'react'
import './App.css'
type Exercise = {
name: string
sets: number
reps: string
rest_seconds: number
instructions: string
}
type WorkoutPlan = {
title: string
overview: string
warmup: string[]
exercises: Exercise[]
cooldown: string[]
safety_note: string
}
type PreviewResponse = {
plan: WorkoutPlan
}
const API_URL = 'http://127.0.0.1:8000'
function App() {
const [goal, setGoal] = useState('Build general strength')
const [experienceLevel, setExperienceLevel] = useState('Beginner')
const [equipment, setEquipment] = useState('Dumbbells and a bench')
const [durationMinutes, setDurationMinutes] = useState(35)
const [plan, setPlan] = useState<WorkoutPlan | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function generateWorkout() {
setLoading(true)
setError('')
setPlan(null)
try {
const response = await fetch(`${API_URL}/workouts/preview`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
goal,
experience_level: experienceLevel,
equipment,
duration_minutes: durationMinutes,
}),
})
if (!response.ok) {
throw new Error((await response.text()) || 'Workout request failed')
}
const data: PreviewResponse = await response.json()
setPlan(data.plan)
} catch (requestError) {
setError(
requestError instanceof Error ? requestError.message : 'Workout request failed',
)
} finally {
setLoading(false)
}
}
return (
<main className="app-shell">
<section className="hero">
<p className="eyebrow">Python AI fitness project</p>
<h1>AI Workout Planner</h1>
<p>Generate a structured session from your goal, experience, equipment, and time.</p>
</section>
<section className="panel">
<form
className="planner-form"
onSubmit={(event) => {
event.preventDefault()
void generateWorkout()
}}
>
<label>
Goal
<input value={goal} onChange={(event) => setGoal(event.target.value)} />
</label>
<label>
Experience level
<select
value={experienceLevel}
onChange={(event) => setExperienceLevel(event.target.value)}
>
<option>Beginner</option>
<option>Intermediate</option>
<option>Advanced</option>
</select>
</label>
<label>
Available equipment
<input
value={equipment}
onChange={(event) => setEquipment(event.target.value)}
/>
</label>
<label>
Session duration in minutes
<input
type="number"
min="20"
max="120"
value={durationMinutes}
onChange={(event) => setDurationMinutes(Number(event.target.value))}
/>
</label>
<button disabled={loading} type="submit">
{loading ? 'Generating...' : 'Generate Workout'}
</button>
</form>
{error && <p className="error">{error}</p>}
</section>
{plan && (
<section className="panel workout-plan">
<h2>{plan.title}</h2>
<p>{plan.overview}</p>
<h3>Warmup</h3>
<ul>
{plan.warmup.map((item) => <li key={item}>{item}</li>)}
</ul>
<h3>Exercises</h3>
<div className="exercise-grid">
{plan.exercises.map((exercise, index) => (
<article className="exercise-card" key={`${exercise.name}-${index}`}>
<h4>{exercise.name}</h4>
<p>{exercise.sets} sets × {exercise.reps}</p>
<p>Rest: {exercise.rest_seconds} seconds</p>
<p>{exercise.instructions}</p>
</article>
))}
</div>
<h3>Cooldown</h3>
<ul>
{plan.cooldown.map((item) => <li key={item}>{item}</li>)}
</ul>
<p className="safety-note">{plan.safety_note}</p>
</section>
)}
</main>
)
}
export default App
What should you compare?
Check the type names, state names, request fields, and closing JSX tags. These pieces must match for TypeScript to compile the form.
Run the first browser request
The form now sends a POST request to the /workouts/preview endpoint. The response should fill every section of the workout display.
Before you submit, do you expect the browser to display the complete validated plan? Your prediction sets the expectation for this request.
- Enter Build general strength in the Goal field.
- Select Beginner in the Experience level field.
- Enter Dumbbells and a bench in the Available equipment field.
- Enter 35 in the Session duration in minutes field.
- Click Generate Workout.
You should see Starter Strength Session with its overview, warmup, three exercise cards, cooldown, and safety note.
That is the complete browser-to-backend path working. Your React form can now send preferences to Python and render a validated response.
Does the request fail?
Confirm that the FastAPI process from earlier is still running. Confirm that API_URL matches the backend address exactly.
Check the FastAPI terminal for the failed request before changing the component. Help me debug the browser request.
Compare contrasting preferences
One successful request proves the connection. A contrasting request reveals whether the backend currently uses the submitted preferences.
Before you submit the second set, do you expect the workout title and exercises to change? Keep that prediction in mind for the comparison.
- Replace the Goal value with Improve cardio endurance.
- Select Advanced in the Experience level field.
- Replace the Available equipment value with A jump rope.
- Replace the Session duration in minutes value with 20.
- Click Generate Workout.
The second request succeeds. You still see Starter Strength Session with the same exercises.
What did this comparison prove?
Both submissions reach FastAPI in the validated shape. The current preview_workout() function returns its fixed plan without using the preference values.
This limitation is intentional. It isolates the working request contract before personalized generation adds another moving part.
Your full-stack request path is working, and you have seen the exact gap left by the fixed preview. Next up, you will replace that preview with a structured workout shaped by each preference.
Generate Structured AI Workouts
Your React form now carries every preference into FastAPI. The fixed plan still throws those values away.
OpenAI Structured Outputs asks the model for data that matches WorkoutPlan. Pydantic validates the parsed result before the browser renders it.
In this step, get ready to:
- Store the OpenAI API key inside the backend environment.
- Generate a personalized plan through a structured FastAPI endpoint.
- Connect the React form to the new endpoint and compare two workouts.
Store the API key on the server
An environment variable lets the Python process read a credential without placing it in React or committed source code. Your existing .gitignore already excludes backend/.env.
API keys grant access to metered requests, so keep the value inside the backend file. The browser never needs to receive or display it.
- Return to backend/.env from your earlier setup in the VS Code Explorer.
- Create an API key in your OpenAI account if you do not already have one.
- Copy the API key.
- Replace your-api-key-here after OPENAI_API_KEY= with your private key.
- Save backend/.env.
Keep the key private
Leave backend/.env out of screenshots and source control. The public backend/.env.example keeps only the safe placeholder.
- In backend/main.py, replace the import section at the top with this setup:
import os
from functools import lru_cache
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from openai import OpenAI
from pydantic import BaseModel
load_dotenv()
What does this setup do?
- The os module reads values from the server environment.
- The load_dotenv() call loads values from backend/.env when FastAPI starts.
- The lru_cache decorator lets the backend reuse one client instance.
- The OpenAI class provides the server-side API client.
- Save backend/main.py.
- Check the backend terminal from earlier. You should see the server reload without an import traceback.
Seeing an import problem?
Confirm the backend terminal still uses backend/.venv. The pinned dependencies from setup already include the OpenAI SDK and python-dotenv.
Check each import against the snippet above if the traceback points to backend/main.py.
Help me diagnose the backend import traceback
- Below WorkoutResponse in backend/main.py, add the cached client function by copying this code:
@lru_cache
def get_openai_client() -> OpenAI:
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise RuntimeError("OPENAI_API_KEY is missing from backend/.env")
return OpenAI(api_key=api_key)
How does the client protect the key?
- The function reads OPENAI_API_KEY inside Python.
- The missing-value check stops generation before an unauthenticated request is sent.
- The cached return value avoids rebuilding the client for every workout.
- Save backend/main.py.
- Check the backend terminal. You should see another clean reload with no traceback.
Backend failing after the reload?
Check that get_openai_client() sits outside every model class and route function. Its decorator must begin at the far left of the file.
Help me fix the OpenAI client setup
Build the structured generation endpoint
The endpoint needs a response contract for the generated plan. Its id field prepares the response for database persistence in the next step.
- Find PreviewResponse in backend/main.py.
- Add this response model directly below it:
class WorkoutResponse(BaseModel):
id: str
plan: WorkoutPlan
What does this model guarantee?
FastAPI uses WorkoutResponse to validate the endpoint result. Every successful response contains an id string plus a plan matching WorkoutPlan.
- Save backend/main.py.
- Check the backend terminal. You should see a clean reload after the new model is defined.
Seeing a model-name error?
Place WorkoutResponse below WorkoutPlan so Python knows the nested type when it reads the class.
Help me fix the response model definition
The generation route sends the submitted preferences as a user message. The model must return data that parses directly into WorkoutPlan.
- Add the generation route below preview_workout() by copying this code:
@app.post("/workouts/generate", response_model=WorkoutResponse)
def generate_workout(preferences: WorkoutRequest) -> WorkoutResponse:
response = get_openai_client().responses.parse(
model="gpt-5.6-luna",
input=[
{
"role": "system",
"content": (
"Create concise, conservative workout ideas for general education, "
"not medical treatment. Use three to five exercises. Include a warmup, "
"cooldown, clear sets and reps, rest in seconds, simple instructions, "
"and a safety note. Do not diagnose conditions or promise outcomes."
),
},
{
"role": "user",
"content": (
f"Goal: {preferences.goal}\n"
f"Experience level: {preferences.experience_level}\n"
f"Available equipment: {preferences.equipment}\n"
f"Session duration: {preferences.duration_minutes} minutes"
),
},
],
text_format=WorkoutPlan,
)
plan = response.output_parsed
if plan is None:
raise RuntimeError("OpenAI did not return a workout plan")
return WorkoutResponse(id="", plan=plan)
How does structured generation work?
- The system message sets conservative fitness boundaries and requests each required workout section.
- The user message includes the four values from WorkoutRequest.
- The text_format=WorkoutPlan argument asks the SDK to parse the response into the existing schema.
- The output_parsed check prevents an empty parsed result from reaching React.
- Save backend/main.py.
- Check the backend terminal. You should see the server reload with the new route available.
Generation route not loading?
Check that generate_workout() begins at the far left of the file. Confirm that every closing bracket in the input list matches the full-code reference below.
Help me debug the structured generation route
✔️ Awesome, I've got everything!
Great. Your backend now has the cached client and structured generation route.
ⓧ I'd like to double check the full code
import os
from functools import lru_cache
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from openai import OpenAI
from pydantic import BaseModel
load_dotenv()
app = FastAPI(title="AI Workout Planner API")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=False,
allow_methods=["GET", "POST"],
allow_headers=["Content-Type"],
)
class WorkoutRequest(BaseModel):
goal: str
experience_level: str
equipment: str
duration_minutes: int
class Exercise(BaseModel):
name: str
sets: int
reps: str
rest_seconds: int
instructions: str
class WorkoutPlan(BaseModel):
title: str
overview: str
warmup: list[str]
exercises: list[Exercise]
cooldown: list[str]
safety_note: str
class PreviewResponse(BaseModel):
plan: WorkoutPlan
class WorkoutResponse(BaseModel):
id: str
plan: WorkoutPlan
@lru_cache
def get_openai_client() -> OpenAI:
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise RuntimeError("OPENAI_API_KEY is missing from backend/.env")
return OpenAI(api_key=api_key)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/workouts/preview", response_model=PreviewResponse)
def preview_workout(preferences: WorkoutRequest) -> PreviewResponse:
plan = WorkoutPlan(
title="Starter Strength Session",
overview="A fixed preview that proves the API contract before personalization.",
warmup=["Five minutes of easy movement", "Bodyweight mobility flow"],
exercises=[
Exercise(
name="Bodyweight Squat",
sets=3,
reps="10",
rest_seconds=60,
instructions="Use a comfortable range of motion and controlled tempo.",
),
Exercise(
name="Incline Push-Up",
sets=3,
reps="8",
rest_seconds=60,
instructions="Choose an incline that lets every repetition stay controlled.",
),
Exercise(
name="Glute Bridge",
sets=3,
reps="12",
rest_seconds=45,
instructions="Pause briefly at the top without overextending your back.",
),
],
cooldown=["Slow breathing", "Gentle lower-body stretching"],
safety_note="This is educational fitness content, not medical advice. Stop if you feel pain.",
)
return PreviewResponse(plan=plan)
@app.post("/workouts/generate", response_model=WorkoutResponse)
def generate_workout(preferences: WorkoutRequest) -> WorkoutResponse:
response = get_openai_client().responses.parse(
model="gpt-5.6-luna",
input=[
{
"role": "system",
"content": (
"Create concise, conservative workout ideas for general education, "
"not medical treatment. Use three to five exercises. Include a warmup, "
"cooldown, clear sets and reps, rest in seconds, simple instructions, "
"and a safety note. Do not diagnose conditions or promise outcomes."
),
},
{
"role": "user",
"content": (
f"Goal: {preferences.goal}\n"
f"Experience level: {preferences.experience_level}\n"
f"Available equipment: {preferences.equipment}\n"
f"Session duration: {preferences.duration_minutes} minutes"
),
},
],
text_format=WorkoutPlan,
)
plan = response.output_parsed
if plan is None:
raise RuntimeError("OpenAI did not return a workout plan")
return WorkoutResponse(id="", plan=plan)
How to use this reference
Compare this file with backend/main.py in VS Code. Keep your real API key inside backend/.env.
Before you send the request, do you expect the generated workout to preserve every field from the preview contract?
- Return to the FastAPI documentation at http://127.0.0.1:8000/docs.
- Expand POST /workouts/generate.
- Click Try it out.
- Set goal to Build general strength.
- Set experience_level to Beginner.
- Set equipment to Dumbbells and a bench.
- Set duration_minutes to 35.
- Click Execute.
The request can take a few seconds while the model creates the plan. You will see a successful response containing a title, overview, warmup, exercises, cooldown, and safety note.
That is the key handoff working. FastAPI now converts the model response into the same typed structure your frontend already understands.
Request did not return a plan?
Check the backend terminal for a credential or account-usage problem. Confirm that the key in backend/.env has no quotes or spaces around it.
Check the route body against the full-code reference if the server reports a Python error.
Help me debug the failed generation request
Connect React and compare workouts
The form still calls the fixed preview route. A small response-type update and endpoint swap move the browser onto structured generation.
- Return to frontend/src/App.tsx in VS Code.
- Replace the PreviewResponse type with this response type:
type WorkoutResponse = {
id: string
plan: WorkoutPlan
}
Why include the ID now?
The frontend reads the generated plan from data.plan. The id field gives the response a stable shape for the persistence work that comes next.
- Inside generateWorkout(), replace /workouts/preview with /workouts/generate.
- Replace the response annotation PreviewResponse with WorkoutResponse.
- Save frontend/src/App.tsx.
✔️ Awesome, I've got everything!
Your React form now sends its preferences to the structured generation route.
ⓧ I'd like to double check the full code
import { useState } from 'react'
import './App.css'
type Exercise = {
name: string
sets: number
reps: string
rest_seconds: number
instructions: string
}
type WorkoutPlan = {
title: string
overview: string
warmup: string[]
exercises: Exercise[]
cooldown: string[]
safety_note: string
}
type WorkoutResponse = {
id: string
plan: WorkoutPlan
}
const API_URL = 'http://127.0.0.1:8000'
function App() {
const [goal, setGoal] = useState('Build general strength')
const [experienceLevel, setExperienceLevel] = useState('Beginner')
const [equipment, setEquipment] = useState('Dumbbells and a bench')
const [durationMinutes, setDurationMinutes] = useState(35)
const [plan, setPlan] = useState<WorkoutPlan | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function generateWorkout() {
setLoading(true)
setError('')
setPlan(null)
try {
const response = await fetch(`${API_URL}/workouts/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
goal,
experience_level: experienceLevel,
equipment,
duration_minutes: durationMinutes,
}),
})
if (!response.ok) {
throw new Error((await response.text()) || 'Workout request failed')
}
const data: WorkoutResponse = await response.json()
setPlan(data.plan)
} catch (requestError) {
setError(
requestError instanceof Error ? requestError.message : 'Workout request failed',
)
} finally {
setLoading(false)
}
}
return (
<main className="app-shell">
<section className="hero">
<p className="eyebrow">Python AI fitness project</p>
<h1>AI Workout Planner</h1>
<p>Generate a structured session from your goal, experience, equipment, and time.</p>
</section>
<section className="panel">
<form
className="planner-form"
onSubmit={(event) => {
event.preventDefault()
void generateWorkout()
}}
>
<label>
Goal
<input value={goal} onChange={(event) => setGoal(event.target.value)} />
</label>
<label>
Experience level
<select
value={experienceLevel}
onChange={(event) => setExperienceLevel(event.target.value)}
>
<option>Beginner</option>
<option>Intermediate</option>
<option>Advanced</option>
</select>
</label>
<label>
Available equipment
<input
value={equipment}
onChange={(event) => setEquipment(event.target.value)}
/>
</label>
<label>
Session duration in minutes
<input
type="number"
min="20"
max="120"
value={durationMinutes}
onChange={(event) => setDurationMinutes(Number(event.target.value))}
/>
</label>
<button disabled={loading} type="submit">
{loading ? 'Generating...' : 'Generate Workout'}
</button>
</form>
{error && <p className="error">{error}</p>}
</section>
{plan && (
<section className="panel workout-plan">
<h2>{plan.title}</h2>
<p>{plan.overview}</p>
<h3>Warmup</h3>
<ul>
{plan.warmup.map((item) => <li key={item}>{item}</li>)}
</ul>
<h3>Exercises</h3>
<div className="exercise-grid">
{plan.exercises.map((exercise, index) => (
<article className="exercise-card" key={`${exercise.name}-${index}`}>
<h4>{exercise.name}</h4>
<p>{exercise.sets} sets × {exercise.reps}</p>
<p>Rest: {exercise.rest_seconds} seconds</p>
<p>{exercise.instructions}</p>
</article>
))}
</div>
<h3>Cooldown</h3>
<ul>
{plan.cooldown.map((item) => <li key={item}>{item}</li>)}
</ul>
<p className="safety-note">{plan.safety_note}</p>
</section>
)}
</main>
)
}
export default App
How to use this reference
Compare the response type and request route with your frontend/src/App.tsx file. Your existing form and workout rendering stay in place.
Each click now uses your metered OpenAI API credits. Before you submit both versions, which workout details do you expect to change while the response structure stays consistent?
- Return to the React app at http://localhost:5173.
- Enter Build general strength in the Goal field.
- Select Beginner in the Experience level field.
- Enter Dumbbells and a bench in the Available equipment field.
- Enter 35 in the Session duration in minutes field.
- Click Generate Workout.
You will see a structured plan with a personalized title and exercise cards. The warmup, cooldown, and safety note remain present.
- Replace the goal with Improve cardio endurance.
- Select Intermediate in the experience field.
- Replace the equipment with Treadmill and bodyweight.
- Click Generate Workout again.
You will see a different workout title and a different exercise list. Both results keep the same validated sections, which proves personalization now works without losing the contract.
Still seeing the fixed preview?
Confirm that the fetch URL ends with /workouts/generate. Save frontend/src/App.tsx so Vite reloads the browser.
Check both development terminals if the page shows an error. The frontend terminal confirms the React build while the backend terminal shows request failures.
Help me trace the React generation request
Your planner now turns submitted preferences into schema-validated workouts. Next, you will give every generated plan a durable home in Supabase.
Save Workouts to Supabase
Your React form now receives personalized workouts from FastAPI. However, every plan disappears when the page refreshes.
In this step, Supabase gives each workout durable PostgreSQL storage. The browser receives the saved ID without receiving either backend credential.
In this step, get ready to:
- Create a protected table for generated workout plans.
- Connect the Python backend to Supabase with server-side credentials.
- Save each generated workout and display its database ID.
Create the protected workout table
Normal columns keep each preference easy to filter later. A JSONB column preserves the nested workout plan without splitting every exercise into another table.
Row Level Security protects the table from browser roles. The backend keeps narrowly scoped access through service_role.
- Switch back to Visual Studio Code.
- Create schema.sql inside the backend folder from the Explorer sidebar.
- Add the table definition to backend/schema.sql by pasting this code:
create table public.workout_plans (
id uuid primary key,
created_at timestamptz not null,
goal text not null,
experience_level text not null,
equipment text not null,
duration_minutes integer not null,
plan jsonb not null
);
alter table public.workout_plans enable row level security;
revoke all on table public.workout_plans from anon, authenticated;
grant select, insert on table public.workout_plans to service_role;
What does this schema do?
- The id column gives every workout a unique UUID.
- The created_at column records when the backend saved the plan.
- The preference columns keep the original request beside its generated result.
- The plan column stores the complete structured workout as JSONB.
- The final statements block anon access. They also block authenticated access. Only service_role receives permission to select or insert rows.
- Save backend/schema.sql.
- Switch to your Supabase project.
- Select SQL Editor in the Supabase navigation.
- Paste the contents of backend/schema.sql into the query editor.
- Select Run to execute the query once.
- Open Table Editor to confirm the new table.
- Select workout_plans to confirm its seven columns.
You should see an empty workout_plans table with columns for the UUID, timestamp, preferences, and JSON plan.
Table query did not complete?
Check that the query starts with create table public.workout_plans. Make sure you executed the complete contents of backend/schema.sql only once.
If the table already exists, confirm it has the target columns before continuing.
Help me troubleshoot the workout_plans schema query.
✔️ Awesome, I've got everything!
Great. Save backend/schema.sql before you continue.
ⓧ I'd like to double check the full code
create table public.workout_plans (
id uuid primary key,
created_at timestamptz not null,
goal text not null,
experience_level text not null,
equipment text not null,
duration_minutes integer not null,
plan jsonb not null
);
alter table public.workout_plans enable row level security;
revoke all on table public.workout_plans from anon, authenticated;
grant select, insert on table public.workout_plans to service_role;
Connect FastAPI to Supabase
The backend reads its Supabase connection details from environment variables. This keeps the credentials outside the Python source.
This credential can bypass Row Level Security, so treat this part with care. It stays in backend/.env and never enters the React app.
- Return to backend/.env from earlier.
- Add the Supabase environment entries below OPENAI_API_KEY by pasting these lines:
SUPABASE_URL=your-supabase-url-here
SUPABASE_SECRET_KEY=your-supabase-secret-key-here
Why these values stay in the backend
SUPABASE_URL identifies the Supabase project. The existing .gitignore keeps backend/.env out of Git.
SUPABASE_SECRET_KEY authorizes trusted server operations. Exposing it to React would give every browser the same privileged access.
- Switch to your Supabase project.
- Open the Connect dialog.
- Record the project URL here: your Supabase project URL.
- Paste your Supabase project URL over your-supabase-url-here in backend/.env.
- Select Settings in Supabase.
- Select API Keys.
- Copy a new-format secret key beginning with sb_secret_....
- Paste the secret key over your-supabase-secret-key-here in backend/.env.
- Save backend/.env.
Your existing .gitignore excludes backend/.env. The credentials remain available to Python without entering source control.
A cached Supabase client gives the backend one trusted connection path. Its settings disable browser-style session persistence because this client runs on the server.
- In backend/main.py replace the import section at the top with this import group:
import os
from datetime import datetime, timezone
from functools import lru_cache
from uuid import uuid4
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from openai import OpenAI
from pydantic import BaseModel
from supabase import Client, create_client
from supabase.lib.client_options import ClientOptions
What do these imports add?
- datetime and timezone create a timezone-aware saved timestamp.
- uuid4 creates a unique workout ID before the insert.
- Client and create_client provide the Supabase connection.
- ClientOptions configures the connection for trusted server-side use.
- Save backend/main.py.
- Check the backend terminal for a successful reload without a Python traceback.
Backend reload showing an import problem?
Confirm that the virtual environment from earlier is still active in the backend terminal. Check that supabase==2.31.0 remains listed in backend/requirements.txt.
Help me diagnose the Supabase import failure.
- In backend/main.py find the existing get_openai_client() function.
- Add the cached Supabase client directly below get_openai_client() by pasting this function:
@lru_cache
def get_supabase_client() -> Client:
url = os.getenv("SUPABASE_URL")
secret_key = os.getenv("SUPABASE_SECRET_KEY")
if not url or not secret_key:
raise RuntimeError(
"SUPABASE_URL or SUPABASE_SECRET_KEY is missing from backend/.env"
)
return create_client(
url,
secret_key,
options=ClientOptions(
auto_refresh_token=False,
persist_session=False,
),
)
What does this client do?
- lru_cache reuses one configured client across requests.
- os.getenv() reads both values from backend/.env.
- The explicit check stops database work when either value is missing.
- ClientOptions disables token refresh. It also disables session persistence for this server-side connection.
- Save backend/main.py.
- Check the backend terminal for another successful reload without a Python traceback.
Backend stopped after adding the client?
Compare the indentation inside get_supabase_client() with the snippet above. Confirm that both Supabase entries in backend/.env contain real values.
Help me debug get_supabase_client().
Persist each workout and show its ID
The generation route already has the validated request and plan. It can now combine them into one database record before returning the saved UUID to React.
- In backend/main.py locate the empty-plan check inside generate_workout().
- Remove the current return WorkoutResponse(id="", plan=plan) line.
- Add the persistence logic below the empty-plan check by pasting this code:
workout_id = str(uuid4())
record = {
"id": workout_id,
"created_at": datetime.now(timezone.utc).isoformat(),
"goal": preferences.goal,
"experience_level": preferences.experience_level,
"equipment": preferences.equipment,
"duration_minutes": preferences.duration_minutes,
"plan": plan.model_dump(mode="json"),
}
save_response = (
get_supabase_client()
.table("workout_plans")
.insert(record)
.execute()
)
if not save_response.data:
raise RuntimeError("Supabase did not return the saved workout")
return WorkoutResponse(id=save_response.data[0]["id"], plan=plan)
How does the save work?
- uuid4() creates the ID that becomes the row's primary key.
- datetime.now(timezone.utc) records a timezone-aware creation time.
- plan.model_dump(mode="json") converts the validated Pydantic model into JSON-compatible data.
- insert(record) sends the complete row to workout_plans.
- save_response.data[0]["id"] returns the database ID through the typed API response.
- Save backend/main.py.
- Check the backend terminal for a successful reload without a Python traceback.
- Open http://127.0.0.1:8000/docs in your browser.
- Expand POST /workouts/generate in the interactive documentation.
- Submit a sample workout request through the interactive form.
You should receive a response containing a non-empty id plus the validated plan.
Workout request failed while saving?
Confirm that backend/.env contains the correct project URL. Confirm that its secret key begins with sb_secret_....
Check that workout_plans exists in the public schema. Verify that service_role has insert access.
Help me troubleshoot the failed Supabase insert.
✔️ Awesome, I've got everything!
Your backend now generates a workout, saves it, and returns its database ID.
ⓧ I'd like to double check the full code
import os
from datetime import datetime, timezone
from functools import lru_cache
from uuid import uuid4
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from openai import OpenAI
from pydantic import BaseModel
from supabase import Client, create_client
from supabase.lib.client_options import ClientOptions
load_dotenv()
app = FastAPI(title="AI Workout Planner API")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=False,
allow_methods=["GET", "POST"],
allow_headers=["Content-Type"],
)
class WorkoutRequest(BaseModel):
goal: str
experience_level: str
equipment: str
duration_minutes: int
class Exercise(BaseModel):
name: str
sets: int
reps: str
rest_seconds: int
instructions: str
class WorkoutPlan(BaseModel):
title: str
overview: str
warmup: list[str]
exercises: list[Exercise]
cooldown: list[str]
safety_note: str
class PreviewResponse(BaseModel):
plan: WorkoutPlan
class WorkoutResponse(BaseModel):
id: str
plan: WorkoutPlan
@lru_cache
def get_openai_client() -> OpenAI:
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise RuntimeError("OPENAI_API_KEY is missing from backend/.env")
return OpenAI(api_key=api_key)
@lru_cache
def get_supabase_client() -> Client:
url = os.getenv("SUPABASE_URL")
secret_key = os.getenv("SUPABASE_SECRET_KEY")
if not url or not secret_key:
raise RuntimeError(
"SUPABASE_URL or SUPABASE_SECRET_KEY is missing from backend/.env"
)
return create_client(
url,
secret_key,
options=ClientOptions(
auto_refresh_token=False,
persist_session=False,
),
)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/workouts/preview", response_model=PreviewResponse)
def preview_workout(preferences: WorkoutRequest) -> PreviewResponse:
plan = WorkoutPlan(
title="Starter Strength Session",
overview="A fixed preview that proves the API contract before personalization.",
warmup=["Five minutes of easy movement", "Bodyweight mobility flow"],
exercises=[
Exercise(
name="Bodyweight Squat",
sets=3,
reps="10",
rest_seconds=60,
instructions="Use a comfortable range of motion and controlled tempo.",
),
Exercise(
name="Incline Push-Up",
sets=3,
reps="8",
rest_seconds=60,
instructions="Choose an incline that lets every repetition stay controlled.",
),
Exercise(
name="Glute Bridge",
sets=3,
reps="12",
rest_seconds=45,
instructions="Pause briefly at the top without overextending your back.",
),
],
cooldown=["Slow breathing", "Gentle lower-body stretching"],
safety_note="This is educational fitness content, not medical advice. Stop if you feel pain.",
)
return PreviewResponse(plan=plan)
@app.post("/workouts/generate", response_model=WorkoutResponse)
def generate_workout(preferences: WorkoutRequest) -> WorkoutResponse:
response = get_openai_client().responses.parse(
model="gpt-5.6-luna",
input=[
{
"role": "system",
"content": (
"Create concise, conservative workout ideas for general education, "
"not medical treatment. Use three to five exercises. Include a warmup, "
"cooldown, clear sets and reps, rest in seconds, simple instructions, "
"and a safety note. Do not diagnose conditions or promise outcomes."
),
},
{
"role": "user",
"content": (
f"Goal: {preferences.goal}\n"
f"Experience level: {preferences.experience_level}\n"
f"Available equipment: {preferences.equipment}\n"
f"Session duration: {preferences.duration_minutes} minutes"
),
},
],
text_format=WorkoutPlan,
)
plan = response.output_parsed
if plan is None:
raise RuntimeError("OpenAI did not return a workout plan")
workout_id = str(uuid4())
record = {
"id": workout_id,
"created_at": datetime.now(timezone.utc).isoformat(),
"goal": preferences.goal,
"experience_level": preferences.experience_level,
"equipment": preferences.equipment,
"duration_minutes": preferences.duration_minutes,
"plan": plan.model_dump(mode="json"),
}
save_response = (
get_supabase_client()
.table("workout_plans")
.insert(record)
.execute()
)
if not save_response.data:
raise RuntimeError("Supabase did not return the saved workout")
return WorkoutResponse(id=save_response.data[0]["id"], plan=plan)
React already reads the typed WorkoutResponse. A small state value carries its returned ID into the workout panel.
- In frontend/src/App.tsx find the plan state inside App().
- Add the saved-ID state directly below it so the two lines look like this:
const [plan, setPlan] = useState<WorkoutPlan | null>(null)
const [savedId, setSavedId] = useState('')
What does this state hold?
savedId holds the UUID returned by FastAPI. It starts as an empty string before any workout has been saved.
- Find setPlan(null) near the start of generateWorkout().
- Add the saved-ID reset directly below it so the two lines look like this:
setPlan(null)
setSavedId('')
Why clear the saved ID?
The reset removes the previous UUID when a new request starts. Each visible ID belongs to the workout currently displayed.
- Save frontend/src/App.tsx.
- Return to http://localhost:5173.
- Select Generate Workout to confirm the personalized workout still renders.
You should see a generated title, overview, warmup, exercises, cooldown, and safety note.
Workout stopped rendering?
Check that savedId uses the same capitalization in both new lines. Confirm that both lines remain inside App().
Help me fix the savedId state edit.
- In generateWorkout() find the response parsing lines.
- Add the saved-ID update so that section looks like this:
const data: WorkoutResponse = await response.json()
setPlan(data.plan)
setSavedId(data.id)
How does React receive the ID?
response.json() produces the typed response. setSavedId(data.id) stores the UUID returned beside the plan.
- Find the rendered workout section inside the plan condition.
- Add the saved-ID paragraph above the workout title so the section starts like this:
<section className="panel workout-plan">
<p className="saved-id">Saved plan ID: {savedId}</p>
<h2>{plan.title}</h2>
What changes in the workout panel?
The saved-id paragraph renders the database UUID above the plan title. The existing stylesheet gives that value a distinct monospace style.
- Save frontend/src/App.tsx.
✔️ Awesome, I've got everything!
Your React app now stores the returned UUID in state and displays it above the generated workout.
ⓧ I'd like to double check the full code
import { useState } from 'react'
import './App.css'
type Exercise = {
name: string
sets: number
reps: string
rest_seconds: number
instructions: string
}
type WorkoutPlan = {
title: string
overview: string
warmup: string[]
exercises: Exercise[]
cooldown: string[]
safety_note: string
}
type WorkoutResponse = {
id: string
plan: WorkoutPlan
}
const API_URL = 'http://127.0.0.1:8000'
function App() {
const [goal, setGoal] = useState('Build general strength')
const [experienceLevel, setExperienceLevel] = useState('Beginner')
const [equipment, setEquipment] = useState('Dumbbells and a bench')
const [durationMinutes, setDurationMinutes] = useState(35)
const [plan, setPlan] = useState<WorkoutPlan | null>(null)
const [savedId, setSavedId] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function generateWorkout() {
setLoading(true)
setError('')
setPlan(null)
setSavedId('')
try {
const response = await fetch(`${API_URL}/workouts/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
goal,
experience_level: experienceLevel,
equipment,
duration_minutes: durationMinutes,
}),
})
if (!response.ok) {
throw new Error((await response.text()) || 'Workout request failed')
}
const data: WorkoutResponse = await response.json()
setPlan(data.plan)
setSavedId(data.id)
} catch (requestError) {
setError(
requestError instanceof Error ? requestError.message : 'Workout request failed',
)
} finally {
setLoading(false)
}
}
return (
<main className="app-shell">
<section className="hero">
<p className="eyebrow">Python AI fitness project</p>
<h1>AI Workout Planner</h1>
<p>Generate a structured session from your goal, experience, equipment, and time.</p>
</section>
<section className="panel">
<form
className="planner-form"
onSubmit={(event) => {
event.preventDefault()
void generateWorkout()
}}
>
<label>
Goal
<input value={goal} onChange={(event) => setGoal(event.target.value)} />
</label>
<label>
Experience level
<select
value={experienceLevel}
onChange={(event) => setExperienceLevel(event.target.value)}
>
<option>Beginner</option>
<option>Intermediate</option>
<option>Advanced</option>
</select>
</label>
<label>
Available equipment
<input
value={equipment}
onChange={(event) => setEquipment(event.target.value)}
/>
</label>
<label>
Session duration in minutes
<input
type="number"
min="20"
max="120"
value={durationMinutes}
onChange={(event) => setDurationMinutes(Number(event.target.value))}
/>
</label>
<button disabled={loading} type="submit">
{loading ? 'Generating...' : 'Generate Workout'}
</button>
</form>
{error && <p className="error">{error}</p>}
</section>
{plan && (
<section className="panel workout-plan">
<p className="saved-id">Saved plan ID: {savedId}</p>
<h2>{plan.title}</h2>
<p>{plan.overview}</p>
<h3>Warmup</h3>
<ul>
{plan.warmup.map((item) => <li key={item}>{item}</li>)}
</ul>
<h3>Exercises</h3>
<div className="exercise-grid">
{plan.exercises.map((exercise, index) => (
<article className="exercise-card" key={`${exercise.name}-${index}`}>
<h4>{exercise.name}</h4>
<p>{exercise.sets} sets × {exercise.reps}</p>
<p>Rest: {exercise.rest_seconds} seconds</p>
<p>{exercise.instructions}</p>
</article>
))}
</div>
<h3>Cooldown</h3>
<ul>
{plan.cooldown.map((item) => <li key={item}>{item}</li>)}
</ul>
<p className="safety-note">{plan.safety_note}</p>
</section>
)}
</main>
)
}
export default App
Before you generate a workout, ask yourself whether the ID in React will match the ID stored in Supabase.
- Return to http://localhost:5173.
- Enter a new workout goal in the Goal field.
- Select Generate Workout.
- Record the displayed ID here: your saved workout ID.
- Switch to the Supabase Table Editor.
- Select the workout_plans table.
- Find the row whose id matches your saved workout ID.
You should see the same ID in both places. The matching row should also contain your submitted preferences plus the structured workout in plan.
Saved ID missing or unmatched?
Confirm that React calls setSavedId(data.id) after parsing the response. Check that the page renders Saved plan ID: {savedId} inside the workout section.
Refresh the Supabase table after generating the workout. Filter visually for the newest created_at value if several rows are present.
Help me trace a missing saved workout ID.
That is durable persistence working. Your generated workout now survives a page refresh as a protected database row.
Secret mission
Load Your Recent Plans
Your planner saves every generated workout, but the browser only shows the latest result. Add a typed read endpoint and a recent-plans view that displays the five newest saved workouts.
Clean Up Your Resources
Clean Up Your Resources
Your local servers have no ongoing cloud cost. Choose whether to keep your Supabase data available, pause local development, or delete the project resources.
Cost warning
The workout_plans table remains on the Supabase Free plan. Requests to the OpenAI API remain metered whenever your backend generates a workout.
Stopping the backend prevents this app from sending further workout requests.
Resources you used:
- The FastAPI backend process running in its terminal.
- The Vite frontend process running in its terminal.
- The local ai-workout-planner folder containing the backend source files, Python virtual environment, and React project.
- The workout_plans table containing your saved workout plans.
- The server-side credentials stored in backend/.env.
Keep everything running
No action is needed. Choose this if you are still generating workouts or extending the recent-plans view.
- Keep the ai-workout-planner folder on your computer.
- Keep the workout_plans table for the progression-tracking follow-on project.
- Keep backend/.env excluded through the existing .gitignore file.
- Leave the local servers running only while you are actively using the app.
Protect Your Credentials
Your OpenAI key and Supabase secret key belong only in backend/.env. Keep that file out of source control.
Pause - I'll come back to this later
Stop both local development processes to free up memory. Your project files and saved workout rows remain available for later.
- Switch back to the FastAPI terminal from earlier.
- Press Ctrl+C to stop the backend.
- Switch back to the Vite terminal from earlier.
- Press Ctrl+C to stop the frontend.
Both terminals should return to the Command Prompt. Your Supabase rows remain stored until you delete the table.
Delete - I don't want to use this again
Deleting the project folder is permanent. The Supabase table is a separate resource that you remove through the dashboard.
- Stop the FastAPI process by pressing Ctrl+C in its terminal.
- Stop the Vite process by pressing Ctrl+C in its terminal.
- Return to the folder containing ai-workout-planner in File Explorer.
- Permanently delete the ai-workout-planner folder using File Explorer's deletion option.
- Confirm that ai-workout-planner no longer appears in the containing folder.
This removes the local code, the Python virtual environment, the React project, and backend/.env.
The table deletion control can be easy to miss because it sits with table-level actions.
- Return to your Supabase project from earlier.
- Select Table Editor.
- Select the workout_plans table.
- Use the table-level deletion option to remove workout_plans.
- Confirm that workout_plans no longer appears in Table Editor.
Rotate an Exposed Key
A Supabase secret key bypasses Row Level Security. Exposure gives that key broad access to your database.
Rotation is only needed if the key was committed, displayed, or shared outside backend/.env.
- Return to Settings in your Supabase project.
- Select API Keys.
- Use the available rotation control for the exposed secret key.
Nice Work!
Nice Work!
You made it! Your React and FastAPI app now generates validated workouts that remain available in Supabase.
You've learned how to:
- Built a typed workout form that sends preferences from the browser to a Python API. The page renders the returned plan as clear workout cards.
- Used Pydantic contracts with OpenAI Structured Outputs to generate personalized workout plans with a predictable shape.
- Saved every generated workout in a protected PostgreSQL table. The server-side credentials stay outside the browser.
- Completed the Secret Mission by adding a typed recent-plans endpoint. The React view shows the five newest saved workouts first.
Ready to quiz yourself?