AI Fantasy Football Lineup Optimizer
Build a full-stack app that gets typed lineup picks from Claude using Zod.
Introduction
30 Second Summary
Fantasy football managers spend hours every week agonizing over lineup decisions. What if you could type in your roster, add a few matchup notes, and get back a complete recommendation with confidence scores in seconds?
In this project, you will build a full-stack TypeScript app that sends your fantasy roster to Claude and receives a guaranteed-schema JSON response. You will use Zod to define the exact response shape, and Claude's Structured Outputs to enforce it.
What You'll Build
You will enter your players with position and matchup context, click Optimize, and see a complete lineup card with starters in exact slots, a FLEX pick with reasoning, and confidence percentages for every decision.
By the end of this project, you'll have:
- A working roster form where you enter players with positions and matchup context, select a league format, and receive an AI-optimized lineup displayed with slot assignments, confidence scores, and per-player reasoning.
- A Zod schema that enforces fantasy football lineup rules (1 QB, 2 RB, 2 WR, 1 TE, 1 FLEX) and guarantees Claude's response matches your TypeScript types exactly.
- Full type safety from API call to React render, using zodOutputFormat() and client.messages.parse() to eliminate JSON.parse() guesswork entirely.
- Secret Mission: Extend the schema with waiver wire targets and a backup lineup rendered below your primary recommendations.
Do I need any prerequisites?
This project assumes familiarity with React, TypeScript, Express, and the Anthropic API. You should have built at least one React component and one Express server before starting.
Before We Start
Before any hands-on work, let's commit to what you're building and why. You are learning how to use Claude's Structured Outputs with Zod to get typed, validated JSON responses from an LLM instead of hoping raw text parses correctly.
Set Up the Full-Stack Project
Your lineup optimizer needs two TypeScript projects working together: a React frontend where users enter their roster, and an Express backend that talks to Claude's API.
In this step, you will scaffold both projects, install the dependencies they share (the Anthropic SDK and Zod), and confirm they run before writing any real logic.
In this step, get ready to:
- Scaffold the Express backend with all dependencies installed.
- Scaffold the React frontend with the Vite TypeScript template.
- Verify both servers start without errors.
Scaffold the server
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and the server directory by running these commands:
mkdir lineup-optimizer && cd lineup-optimizer
mkdir server && cd server
- Initialize the server project and install all dependencies by running these commands:
npm init -y
npm pkg set name=lineup-optimizer-server
npm pkg set type=module
npm pkg set scripts.dev="tsx src/index.ts"
npm pkg set scripts.test-schema="tsx src/test-schema.ts"
npm install express@5.2.1 cors@2.8.6 @anthropic-ai/sdk zod
npm install -D tsx @types/node @types/express @types/cors
You will see npm output listing the installed packages and their versions.
What do these packages do?
- express is the web framework that handles HTTP requests from your frontend.
- cors lets the frontend (port 5173) talk to the backend (port 3001) without browser security blocks.
- @anthropic-ai/sdk is the official Anthropic client library for calling Claude.
- zod defines typed schemas that Claude's structured outputs must conform to.
- tsx runs TypeScript files directly without a separate compile step.
Seeing permission errors or EACCES?
On macOS, you may need to prefix the install commands with sudo. On Windows, try running your terminal as Administrator.
If you see a network timeout, check your internet connection and retry the npm install command.
Help me fix npm permission or install errors in my lineup-optimizer project.
- Create the src directory inside server by running:
mkdir src
Now you need to open this project in VS Code to create your first file.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the lineup-optimizer folder, then click Open.
- In the Explorer sidebar, expand server then src.
- Right-click the src folder and select New File.
- Name the file index.ts and press Enter.
- Paste the following code into index.ts:
import express from "express";
import cors from "cors";
// Create the Express app and enable middleware
const app = express();
app.use(cors());
app.use(express.json());
// Placeholder route for lineup optimization
app.post("/api/optimize", async (req, res) => {
res.json({ message: "coming soon" });
});
// Start the server on port 3001
const PORT = 3001;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
What does this code do?
- app.use(cors()) allows cross-origin requests so your React app (port 5173) can reach this server (port 3001).
- app.use(express.json()) parses incoming JSON request bodies into req.body.
- The POST /api/optimize route is a placeholder. You will replace it with the real structured output logic in Step 4.
- Save index.ts with Cmd+S (macOS) or Ctrl+S (Windows).
✔️ Awesome, I've got everything!
Great. Make sure you have saved server/src/index.ts before continuing.
ⓧ I'd like to double check the full code
Here is the complete server/src/index.ts file:
import express from "express";
import cors from "cors";
const app = express();
app.use(cors());
app.use(express.json());
app.post("/api/optimize", async (req, res) => {
res.json({ message: "coming soon" });
});
const PORT = 3001;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Don't see the src folder in Explorer?
Make sure you opened the lineup-optimizer folder (the project root), not the server folder directly. The Explorer should show server/src as a nested folder.
Help me find or create the src folder inside my lineup-optimizer server directory.
Scaffold the client
The frontend uses Vite with React and TypeScript to give you a fast dev server and type-safe components.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, both macOS and Windows).
- Make sure you are in the lineup-optimizer root folder (not inside server). Navigate there by running:
cd ~/Desktop/lineup-optimizer
- Scaffold the React client and install its dependencies by running these commands:
npm create vite@latest client -- --template react-ts
cd client
npm install
You will see Vite create a client folder with files like src/App.tsx, src/main.tsx, and vite.config.ts. Then npm install fetches all the starter dependencies.
Seeing a 'create-vite' not found error?
Make sure you are connected to the internet. The npm create vite@latest command downloads the scaffolding tool on the fly. If it still fails, try running npx create-vite@latest client --template react-ts as an alternative.
Help me troubleshoot the Vite scaffold command failing.
Verify both servers start
Both projects are scaffolded. Before moving on, confirm each one starts without errors.
- In your VS Code terminal, navigate to the server directory by running:
cd ~/Desktop/lineup-optimizer/server
Before you run the server, what do you expect to see in the terminal if everything installed correctly?
- Start the server by running:
npx tsx src/index.ts
You should see: Server running on http://localhost:3001.
- Open a second terminal panel by clicking the + icon in the terminal tab bar (or press Cmd+Shift+` on macOS, Ctrl+Shift+` on Windows).
- Navigate to the client directory and start the Vite dev server by running:
cd ~/Desktop/lineup-optimizer/client
npm run dev
You should see Vite print a local URL at http://localhost:5173. Open that URL in your browser and you will see the default Vite + React starter page.
Server not starting or port already in use?
If you see EADDRINUSE, another process is using port 3001 or 5173. Close any other dev servers running in other terminal windows and try again.
If npx tsx src/index.ts says the file is not found, double-check that you created index.ts inside server/src/ (not directly in server/).
Help me fix my server or client startup errors.
Your environment is ready. Both servers are running and waiting for real logic. Next up, you will define the Zod schema that guarantees Claude returns structured lineup data instead of raw text.
Build the Lineup Schema and Structured API Call
Your server and client are both running. The Express backend responds to requests and the Vite app loads in the browser.
Now comes the core problem this project solves. When you ask an LLM to return JSON via a regular prompt, you get raw text back. You have to write fragile JSON.parse() calls and hope the model formatted things correctly. In this step, you will define a Zod schema and use zodOutputFormat() with client.messages.parse() to guarantee Claude's response matches your TypeScript types exactly.
In this step, get ready to:
- Define Zod schemas for roster input and lineup output.
- Write a test script that compares raw prompting against structured output.
- Run the test and observe typed, validated JSON returned from Claude.
Define the Zod schemas for input and output
Your lineup optimizer needs two kinds of schemas. Input schemas describe what the user sends (their roster and league settings). Output schemas describe the exact shape of Claude's response (starters, bench, FLEX pick, confidence scores, reasoning).
- In VS Code, create a new file called schema.ts inside the server/src/ folder.
- Add the input schemas and the first output schema by pasting this code:
import { z } from "zod";
export const RosterEntrySchema = z.object({
name: z.string(),
position: z.enum(["QB", "RB", "WR", "TE", "K", "DST"]),
context: z.string().optional(),
});
export const LineupRequestSchema = z.object({
roster: z.array(RosterEntrySchema),
leagueFormat: z.enum(["PPR", "Half-PPR", "Standard"]),
lineupRequirements: z.object({
qb: z.number().default(1),
rb: z.number().default(2),
wr: z.number().default(2),
te: z.number().default(1),
flex: z.number().default(1),
}),
});
export const StarterSchema = z.object({
name: z.string(),
position: z.enum(["QB", "RB", "WR", "TE", "K", "DST"]),
slot: z.string(),
confidence: z.number().min(0).max(100),
reasoning: z.string(),
});
What does this code do?
- RosterEntrySchema defines a single player: their name, position (from a fixed set of football positions), and optional context like matchup or injury info.
- LineupRequestSchema wraps the full request: an array of roster entries, the league format, and how many starters are needed at each position.
- StarterSchema describes one starter in the response: their slot label (like "QB1" or "RB2"), a confidence score from 0 to 100, and reasoning text.
- Now add the remaining output schemas and type exports below the StarterSchema in the same file:
export const BenchPlayerSchema = z.object({
name: z.string(),
position: z.enum(["QB", "RB", "WR", "TE", "K", "DST"]),
reasoning: z.string(),
});
export const FlexChoiceSchema = z.object({
name: z.string(),
position: z.enum(["RB", "WR", "TE"]),
reasoning: z.string(),
confidence: z.number().min(0).max(100),
});
export const PositionalNoteSchema = z.object({
position: z.string(),
note: z.string(),
});
export const LineupResponseSchema = z.object({
starters: z.array(StarterSchema),
bench: z.array(BenchPlayerSchema),
flexChoice: FlexChoiceSchema,
overallConfidence: z.number().min(0).max(100),
positionalNotes: z.array(PositionalNoteSchema),
summary: z.string(),
});
export type RosterEntry = z.infer<typeof RosterEntrySchema>;
export type LineupRequest = z.infer<typeof LineupRequestSchema>;
export type LineupResponse = z.infer<typeof LineupResponseSchema>;
What does this code do?
- BenchPlayerSchema and FlexChoiceSchema describe benched players and the FLEX slot pick. Notice FlexChoiceSchema only allows RB, WR, or TE since those are the FLEX-eligible positions.
- LineupResponseSchema composes all the smaller schemas into the full response shape Claude must return. This is the schema you will pass to zodOutputFormat().
- The z.infer<> exports create TypeScript types directly from the schemas. Your code gets full type safety without writing types separately.
- Save schema.ts.
- Confirm the file exists by running this in your server terminal:
ls src/schema.ts
You should see src/schema.ts printed in the output.
Don't see the file?
- Make sure you are in the server directory when running ls. Run pwd to check.
- Confirm you saved the file inside server/src/ and not in the project root.
- help me find where my schema.ts file ended up
✔️ Awesome, I've got everything!
Great. Make sure schema.ts is saved before moving on.
ⓧ I'd like to double check the full code
Your schema.ts file should contain these sections in order. Scroll up and confirm each is present:
- The import { z } from "zod" line at the top.
- RosterEntrySchema with name, position enum, and optional context.
- LineupRequestSchema with roster array, leagueFormat, and lineupRequirements.
- StarterSchema with name, position, slot, confidence, and reasoning.
- BenchPlayerSchema with name, position, and reasoning.
- FlexChoiceSchema with RB/WR/TE position enum, reasoning, and confidence.
- PositionalNoteSchema with position and note.
- LineupResponseSchema composing all schemas above.
- Three export type lines using z.infer<>.
Write a test script that compares raw and structured calls
You will write a single script that makes two API calls to Claude with the same roster. The first call uses a plain prompt and returns unstructured text. The second call uses zodOutputFormat() and returns a typed, validated object.
- Create a new file called test-schema.ts inside server/src/ and paste the first section:
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { LineupResponseSchema } from "./schema.js";
const client = new Anthropic();
const sampleRoster = `Patrick Mahomes (QB) - vs weak secondary
Derrick Henry (RB) - projected 16.2 points
Saquon Barkley (RB) - vs top rush defense
Ja'Marr Chase (WR) - target leader, PPR monster
CeeDee Lamb (WR) - questionable with ankle
Travis Kelce (TE) - consistent floor
Chris Olave (WR) - boom/bust, favorable matchup
David Montgomery (RB) - steady 12-point floor`;
async function main() {
console.log("--- RAW PROMPT (unstructured) ---\n");
// First call: plain prompt, no schema enforcement
const rawMessage = await client.messages.create({
model: "claude-sonnet-5",
max_tokens: 1024,
messages: [
{
role: "user",
content: `Recommend a fantasy lineup from this roster (1 QB, 2 RB, 2 WR, 1 TE, 1 FLEX). PPR format.\n\n${sampleRoster}`,
},
],
});
What does this code do?
- The zodOutputFormat import and LineupResponseSchema import wire together your schema with the Anthropic SDK's structured output helper.
- new Anthropic() creates the client. It reads your ANTHROPIC_API_KEY environment variable automatically.
- The first call uses client.messages.create() with a plain text prompt. This is the raw approach that returns free-form text you would need to parse yourself.
- Add the rest of the script below the rawMessage call:
// Print first 300 chars of raw response
for (const block of rawMessage.content) {
if (block.type === "text") {
console.log(block.text.slice(0, 300) + "...\n");
}
}
console.log("^ Unstructured text. You would need to parse this yourself.\n");
console.log("--- STRUCTURED OUTPUT (zodOutputFormat) ---\n");
// Second call: schema-enforced structured output
const structuredMessage = await client.messages.parse({
model: "claude-sonnet-5",
max_tokens: 2048,
system: `You are a fantasy football lineup optimizer. Analyze ONLY the information provided. Lineup: 1 QB, 2 RB, 2 WR, 1 TE, 1 FLEX (RB/WR/TE). Format: PPR. Use slot labels QB1, RB1, RB2, WR1, WR2, TE1. Each player appears once. FLEX must not duplicate a starter.`,
messages: [
{
role: "user",
content: `Here is my roster:\n\n${sampleRoster}\n\nRecommend my optimal lineup.`,
},
],
output_config: {
format: zodOutputFormat(LineupResponseSchema),
},
});
// Access typed fields directly - no JSON.parse() needed
if (structuredMessage.parsed_output) {
console.log("Typed parsed_output:");
console.log(JSON.stringify(structuredMessage.parsed_output, null, 2));
console.log("\nDirect field access:");
console.log("FLEX pick:", structuredMessage.parsed_output.flexChoice.name);
console.log("FLEX confidence:", structuredMessage.parsed_output.flexChoice.confidence);
console.log("Overall confidence:", structuredMessage.parsed_output.overallConfidence);
}
}
main().catch(console.error);
What does this code do?
- The first section prints the raw response text (truncated to 300 characters) to show what unstructured output looks like.
- client.messages.parse() is the structured output method. Combined with zodOutputFormat(LineupResponseSchema) in output_config, it tells Claude to conform to your exact schema.
- structuredMessage.parsed_output is already typed as LineupResponse. You can access .flexChoice.confidence directly with full TypeScript autocompletion. No parsing needed.
- Save test-schema.ts.
Seeing a red underline on the import?
- Make sure your import uses "./schema.js" (with the .js extension), not "./schema.ts". ESM requires the compiled extension in import paths.
- Confirm both files are in the same server/src/ directory.
- fix my test-schema.ts import error
✔️ Awesome, I've got everything!
Great. Make sure test-schema.ts is saved before running it.
ⓧ I'd like to double check the full code
Your test-schema.ts should contain these sections in order. Scroll up and confirm each is present:
- Three import lines: Anthropic, zodOutputFormat, and LineupResponseSchema.
- const client = new Anthropic() and the sampleRoster template literal with 8 players.
- async function main() starting with the raw client.messages.create() call.
- A for...of loop printing truncated raw output.
- The structured client.messages.parse() call with zodOutputFormat(LineupResponseSchema) in output_config.
- The if (structuredMessage.parsed_output) block with JSON.stringify and direct field access logs.
- main().catch(console.error) at the bottom.
Run the test script and observe the difference
Both files are ready. Before you run the test, think about what you expect to see from each call.
- Make sure your ANTHROPIC_API_KEY environment variable is set in your terminal. If you have not exported it yet, run:
export ANTHROPIC_API_KEY=your-api-key-here
Do you think both calls will return the same kind of output, or will they look different? What would you need to do with raw text to extract a player's confidence score?
- Run the test script:
npx tsx src/test-schema.ts
The script makes two API calls, so it takes a few seconds. You will see two distinct sections of output.
The first section (RAW PROMPT) shows free-form text. Claude writes a helpful response, but it is unstructured prose. To extract any specific field, you would need regex or string splitting.
The second section (STRUCTURED OUTPUT) shows the exact JSON object matching your Zod schema. Every field is present and correctly typed. The direct field access lines at the bottom prove you can read flexChoice.name and overallConfidence without any parsing.
Why is this better than prompting for JSON?
With zodOutputFormat(), Claude's output is constrained at the model level to conform to your schema. It is not just "asked nicely" to return JSON. The SDK validates the response with your Zod schema and gives you a typed parsed_output object. If the response ever fails validation (due to truncation or refusal), parsed_output is null instead of a corrupt partial object.
This eliminates retry loops, try/catch around JSON.parse(), and silent data corruption from missing fields.
Seeing an authentication error?
- Confirm your ANTHROPIC_API_KEY is set correctly by running echo $ANTHROPIC_API_KEY. You should see your key printed (it starts with sk-ant-).
- If you opened a new terminal tab, you need to export the key again in that tab.
- fix my Anthropic API authentication error
Your schema is defined and proven. The zodOutputFormat() pattern guarantees typed JSON from Claude. Next up, you will build the React form that collects a real roster from the user.
Build the React Roster Form
Your backend can now produce typed, Zod-validated lineup data. But there is no way for a user to actually submit a roster and trigger that call.
In this step you will build a React form that collects player entries with position and optional context, displays the current roster, and prepares an API call to your Express server.
In this step, get ready to:
- Create a roster entry form with player name, position dropdown, and context input.
- Define TypeScript types matching the server's response schema and wire up an API call.
- Style the two-panel layout with position badges, roster list, and results placeholder.
Create the roster entry form
The Vite starter in your client directory has a default App.tsx with demo content. You will replace it entirely with your own component that manages a roster list.
- In VS Code, open client/src/App.tsx.
- Select all existing content and delete it.
- Paste the following code that defines the roster type, state variables, and handler functions:
import { useState } from "react";
import "./App.css";
type RosterEntry = {
name: string;
position: "QB" | "RB" | "WR" | "TE" | "K" | "DST";
context?: string;
};
function App() {
const [roster, setRoster] = useState<RosterEntry[]>([]);
const [leagueFormat, setLeagueFormat] = useState<"PPR" | "Half-PPR" | "Standard">("PPR");
const [playerName, setPlayerName] = useState("");
const [playerPosition, setPlayerPosition] = useState<RosterEntry["position"]>("QB");
const [playerContext, setPlayerContext] = useState("");
const addPlayer = () => {
if (!playerName.trim()) return;
setRoster([...roster, { name: playerName.trim(), position: playerPosition, context: playerContext.trim() || undefined }]);
setPlayerName("");
setPlayerContext("");
};
const removePlayer = (index: number) => {
setRoster(roster.filter((_, i) => i !== index));
};
What does this code do?
- RosterEntry defines the shape of each player: a name, a position from a fixed set, and an optional context string for matchup notes.
- addPlayer() pushes a new entry onto the roster array and resets the input fields.
- removePlayer() filters out the player at the given index.
- Directly below the handlers (still inside the App function), add the return statement with the league format selector and player input row:
return (
<div className="app">
<h1>Fantasy Football Lineup Optimizer</h1>
<p className="subtitle">Powered by Claude Structured Outputs</p>
<div className="layout">
<div className="panel">
<h2>Your Roster</h2>
<div className="form-group">
<label htmlFor="league-format">League Format</label>
<select id="league-format" value={leagueFormat} onChange={(e) => setLeagueFormat(e.target.value as typeof leagueFormat)}>
<option value="PPR">PPR</option>
<option value="Half-PPR">Half-PPR</option>
<option value="Standard">Standard</option>
</select>
</div>
<div className="add-player">
<input type="text" placeholder="Player name" value={playerName} onChange={(e) => setPlayerName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addPlayer()} />
<select value={playerPosition} onChange={(e) => setPlayerPosition(e.target.value as RosterEntry["position"])}>
<option value="QB">QB</option>
<option value="RB">RB</option>
<option value="WR">WR</option>
<option value="TE">TE</option>
<option value="K">K</option>
<option value="DST">DST</option>
</select>
<input type="text" placeholder="Context (optional): matchup, projection, injury" value={playerContext} onChange={(e) => setPlayerContext(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addPlayer()} />
<button onClick={addPlayer}>Add</button>
</div>
What's happening here?
The form collects three pieces of data per player. The league format dropdown lets the user pick PPR, Half-PPR, or Standard scoring. Both text inputs fire addPlayer() on Enter so the user can add players without clicking.
- Continue directly below. Add the roster list, the results placeholder panel, and close all open tags:
<ul className="roster-list">
{roster.map((player, i) => (
<li key={i}>
<span className="player-pos">{player.position}</span>
<span className="player-name">{player.name}</span>
{player.context && <span className="player-context">{player.context}</span>}
<button className="remove-btn" onClick={() => removePlayer(i)}>X</button>
</li>
))}
</ul>
</div>
<div className="panel results-panel">
<h2>Optimized Lineup</h2>
<p className="placeholder">Add at least 7 players to your roster, then click "Optimize Lineup" to get AI-powered recommendations.</p>
</div>
</div>
</div>
);
}
export default App;
What does the roster list render?
Each player renders as a list item with a position badge, the player name, an optional context note in italics, and a remove button. The right panel is a placeholder that will display AI results in Step 4.
- Save client/src/App.tsx.
- In the terminal where your client is running, confirm the Vite dev server recompiles without errors.
- Switch to your browser at localhost:5173. Type a player name, select a position, and click Add. You should see the player appear in the list with a position label.
Not seeing your roster list?
- Check the browser console for errors. A missing closing tag or mismatched brace will prevent rendering.
- Make sure you pasted all three code sections in order inside the same file. The return statement must sit inside the App function, after the handlers.
- Help me debug my App.tsx roster form
Add the API call and type definitions
The form works for managing a roster locally. Now you need TypeScript types that match the server's LineupResponse schema, plus a function that sends the roster to your backend.
- In client/src/App.tsx, find the line that says function App() {. Above it (but below the RosterEntry type), add these response types:
type Starter = {
name: string;
position: string;
slot: string;
confidence: number;
reasoning: string;
};
type BenchPlayer = {
name: string;
position: string;
reasoning: string;
};
type FlexChoice = {
name: string;
position: string;
reasoning: string;
confidence: number;
};
type PositionalNote = {
position: string;
note: string;
};
type LineupResponse = {
starters: Starter[];
bench: BenchPlayer[];
flexChoice: FlexChoice;
overallConfidence: number;
positionalNotes: PositionalNote[];
summary: string;
};
Why mirror the server types?
These types match the Zod schema you defined in schema.ts on the server. When the API returns a structured response, TypeScript will know the exact shape of every field. No guessing, no runtime surprises.
- Inside the App function, find the line const [leagueFormat, setLeagueFormat]. Directly below it, add three new state variables:
const [result, setResult] = useState<LineupResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
- Below the removePlayer function, add the optimizeLineup function that posts the roster to your backend:
const optimizeLineup = async () => {
setLoading(true);
setError(null);
setResult(null);
try {
const response = await fetch("http://localhost:3001/api/optimize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roster, leagueFormat, lineupRequirements: { qb: 1, rb: 2, wr: 2, te: 1, flex: 1 } }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Request failed");
}
const data: LineupResponse = await response.json();
setResult(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
}
};
What does optimizeLineup do?
- It sends the full roster, league format, and slot requirements to http://localhost:3001/api/optimize as JSON.
- On success, it stores the typed LineupResponse in state. On failure, it captures the error message.
- The finally block always resets loading so the button re-enables whether the call succeeded or failed.
- Now update the JSX. Find the closing </ul> tag of the roster list. Directly below it (and before the panel's closing </div>), add the conditional button and hint:
{roster.length >= 7 && <button className="optimize-btn" onClick={optimizeLineup} disabled={loading}>{loading ? "Optimizing..." : "Optimize Lineup"}</button>}
{roster.length > 0 && roster.length < 7 && <p className="hint">Add at least 7 players to optimize.</p>}
- In the right panel, find the <p className="placeholder"> line. Add an error display directly above it:
{error && <div className="error">{error}</div>}
- Wrap the existing placeholder paragraph in a condition so it only shows when there is no result and no error:
{!result && !error && <p className="placeholder">Add at least 7 players to your roster, then click "Optimize Lineup" to get AI-powered recommendations.</p>}
- Save client/src/App.tsx.
- In your browser, add 7 players to the roster using any names and positions. You should see the green Optimize Lineup button appear below the list. With fewer than 7, a hint message appears instead.
Button not appearing at 7 players?
- Confirm the two conditional lines sit inside the left .panel div, after the </ul> and before the panel's </div>.
- Check the browser console for TypeScript or JSX errors. A missing import or unclosed tag prevents rendering.
- Help me debug why the Optimize Lineup button does not appear
✔️ Awesome, I've got everything!
Great. Double check you have saved client/src/App.tsx and the dev server shows no errors.
ⓧ I'd like to double check the full code
Your client/src/App.tsx is assembled from multiple chunks. Scroll up and confirm these sections appear in order:
- Imports and RosterEntry type at the top.
- Response types (Starter, BenchPlayer, FlexChoice, PositionalNote, LineupResponse) above function App().
- State variables: roster, leagueFormat, result, loading, error, playerName, playerPosition, playerContext.
- Handler functions: addPlayer, removePlayer, optimizeLineup.
- Return JSX: left panel with form, roster list, conditional button. Right panel with error display and conditional placeholder.
- export default App; at the bottom.
Style the form
The form is functional but unstyled. You will replace the Vite default CSS with a clean two-panel grid layout.
- Open client/src/App.css. Select all and delete the existing content.
- Paste the CSS variables and layout styles:
:root {
--primary: #1a73e8;
--primary-dark: #1557b0;
--success: #34a853;
--warning: #fbbc04;
--error: #ea4335;
--bg: #f8f9fa;
--surface: #ffffff;
--text: #202124;
--text-secondary: #5f6368;
--border: #dadce0;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; }
.app { max-width: 1200px; margin: 0 auto; padding: 2rem; }
h1 { font-size: 2rem; margin-bottom: 0.25rem; }
.subtitle { color: var(--text-secondary); margin-bottom: 2rem; }
.layout { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; }
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; }
What do these styles set up?
The CSS variables define a consistent color palette across the app. The .layout class creates a two-column grid so the roster form sits on the left and results on the right. On mobile screens (added next), it collapses to a single column.
- Below the layout styles, add the component-specific styles for player badges, the roster list, buttons, and utility classes:
.player-pos { background: var(--primary); color: white; padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
.player-name { font-weight: 500; }
.player-context { font-size: 0.8rem; color: var(--text-secondary); font-style: italic; }
.roster-list { list-style: none; margin-bottom: 1rem; }
.roster-list li { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; border-bottom: 1px solid var(--border); }
.remove-btn { margin-left: auto; background: none; border: none; color: var(--error); cursor: pointer; font-weight: bold; }
.optimize-btn { width: 100%; padding: 0.75rem; background: var(--success); color: white; border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; }
.hint { color: var(--text-secondary); font-size: 0.875rem; text-align: center; margin-top: 0.5rem; }
.placeholder { color: var(--text-secondary); text-align: center; padding: 2rem 1rem; }
.error { background: #fce8e6; color: var(--error); padding: 0.75rem; border-radius: 6px; margin-bottom: 1rem; }
@media (max-width: 768px) { .layout { grid-template-columns: 1fr; } }
What do these classes style?
- .player-pos creates a small blue badge for the position label (QB, RB, WR, etc.).
- .roster-list li displays each player as a horizontal row with the badge, name, context, and remove button.
- .optimize-btn is the full-width green button that appears once 7 players are added.
- The @media query stacks the panels vertically on narrow screens.
- Save client/src/App.css.
- Also open client/src/index.css and delete all its contents, then save. The Vite default styles in that file conflict with your layout.
Before you look at the browser, predict what the page will look like. Do you expect two side-by-side panels or a single column?
- Switch to your browser. You should see a clean two-panel layout: the left panel has the league format dropdown, input row, and roster list with blue position badges. The right panel shows the placeholder message.
- Add 3-4 players with different positions and context notes. Confirm each entry appears with a badge, name, and italic context. Click the X button to remove one and confirm it disappears.
Seeing unstyled or broken layout?
- Confirm you cleared index.css. The Vite default styles set a centered flex container that overrides your grid.
- Check that both CSS chunks are in App.css in order: variables and layout first, then component styles.
- Help me fix my App.css layout styles
✔️ Awesome, I've got everything!
Great. Make sure both App.tsx and App.css are saved.
ⓧ I'd like to double check the full code
Your client/src/App.css should contain these two sections in order:
- :root variables block with 10 CSS custom properties, followed by single-line rules for *, body, .app, h1, .subtitle, .layout, .panel.
- Component styles: .player-pos, .player-name, .player-context, .roster-list, .roster-list li, .remove-btn, .optimize-btn, .hint, .placeholder, .error, and the @media query.
Your roster form is collecting player data and ready to fire off an optimization request. In the next step, you will wire up the real backend endpoint and render the structured lineup results.
Wire Up the Backend and Display Results
In the previous step, you built a roster form that collects players, positions, and context notes. The form already sends data to the server when you click Optimize Lineup.
The problem is that the server still returns a placeholder response. In this step you will wire the real zodOutputFormat() call into the endpoint and render Claude's structured lineup in the React UI.
In this step, get ready to:
- Implement the real /api/optimize endpoint with Zod validation and structured output.
- Render the structured lineup results in the React results panel.
- Test the full flow from roster entry to AI-optimized lineup display.
Implement the /api/optimize endpoint
The endpoint needs to validate the incoming roster, build a prompt from the player data, and call client.messages.parse() with your LineupResponseSchema.
- Switch to the terminal running your server and press Ctrl+C to stop it.
- Open server/src/index.ts in VS Code.
- Select all the existing code and replace it with the following imports and request validation:
import express from "express";
import cors from "cors";
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { LineupRequestSchema, LineupResponseSchema } from "./schema.js";
const app = express();
app.use(cors());
app.use(express.json());
// Create the Anthropic client (uses ANTHROPIC_API_KEY env var)
const client = new Anthropic();
app.post("/api/optimize", async (req, res) => {
// Validate incoming data against the Zod schema
const parseResult = LineupRequestSchema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({ error: "Invalid request", details: parseResult.error.issues });
return;
}
const { roster, leagueFormat, lineupRequirements } = parseResult.data;
// Format each player as a readable line for the prompt
const rosterText = roster.map((p) => {
let entry = `${p.name} (${p.position})`;
if (p.context) entry += ` - ${p.context}`;
return entry;
}).join("\n");
const totalPositionSlots = lineupRequirements.qb + lineupRequirements.rb + lineupRequirements.wr + lineupRequirements.te;
What does this code do?
- The new imports bring in the Anthropic SDK, the zodOutputFormat helper, and both schemas from your schema.ts file.
- LineupRequestSchema.safeParse() validates the frontend's POST body against your Zod schema. If validation fails, the endpoint returns a 400 with details.
- rosterText formats each player into a human-readable line that Claude can analyze.
- Directly below the totalPositionSlots line, add the system and user prompts:
const systemPrompt = `You are a fantasy football lineup optimizer. You analyze rosters and recommend optimal starting lineups based ONLY on the information the user provides about their players (names, positions, and any context they share like matchups, projections, or injury notes).
IMPORTANT: You do NOT have access to live NFL data, injury reports, rankings, or projections. Base your analysis solely on the submitted roster information. If a player has no context provided, make your best judgment based on general position value in the specified league format.
Lineup requirements: ${lineupRequirements.qb} QB, ${lineupRequirements.rb} RB, ${lineupRequirements.wr} WR, ${lineupRequirements.te} TE, ${lineupRequirements.flex} FLEX (RB/WR/TE eligible).
League format: ${leagueFormat}.
Rules:
- The "starters" array must contain exactly ${totalPositionSlots} players filling position slots only (not FLEX).
- Use slot labels: QB1, RB1, RB2, WR1, WR2, TE1.
- The "flexChoice" is a separate field for the FLEX slot. It must be an RB, WR, or TE who is NOT already in the starters array.
- Each player can appear at most once across starters, flexChoice, and bench.
- All remaining roster players go to the bench array.
- Confidence levels (0-100) reflect how certain you are given the available information. Lower confidence when information is limited.`;
const userPrompt = `Here is my ${leagueFormat} league roster for this week:\n\n${rosterText}\n\nPlease recommend my optimal starting lineup.`;
Why is the system prompt so detailed?
The system prompt acts as a contract. It tells Claude exactly what slot labels to use, that each player can only appear once, and that FLEX must not duplicate a starter.
Because zodOutputFormat() enforces the JSON structure, the system prompt focuses on business logic constraints that a schema alone cannot express.
- Complete the file by adding the API call and server listener directly below the userPrompt line:
try {
const message = await client.messages.parse({
model: "claude-sonnet-5",
max_tokens: 2048,
system: systemPrompt,
messages: [{ role: "user", content: userPrompt }],
output_config: { format: zodOutputFormat(LineupResponseSchema) },
});
if (message.parsed_output === null) {
res.status(500).json({ error: "No structured output returned", stopReason: message.stop_reason });
return;
}
res.json(message.parsed_output);
} catch (error) {
console.error("API error:", error);
res.status(500).json({ error: "Failed to optimize lineup" });
}
});
const PORT = 3001;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
What does this code do?
- client.messages.parse() sends the request to Claude with zodOutputFormat(LineupResponseSchema) in the output_config. Claude's response is guaranteed to match your schema.
- message.parsed_output is the fully typed, Zod-validated result. If it is null, that means Claude refused the request or the response was truncated by max_tokens.
- The try/catch handles network errors or SDK exceptions. Express 5 auto-forwards unhandled async errors, but explicit handling gives you control over the response shape.
- Save server/src/index.ts.
- Restart the server by running:
npx tsx src/index.ts
You should see Server running on http://localhost:3001 in the terminal with no errors.
Seeing a TypeScript or import error?
- Check that the import path ends with .js (./schema.js). ESM requires the extension even for TypeScript files.
- Confirm your package.json has "type": "module".
- Make sure you pasted all three sections in order with no missing brackets.
Help me debug this import error
Render the structured results in the React UI
The frontend already has a results panel with an error display and a placeholder message. You need to add the rendering logic for when result is populated.
- Open client/src/App.tsx in VS Code.
- Find the line that says:
{error && <div className="error">{error}</div>}
- Directly below that line, add the following results rendering block:
{result && (
<div className="results">
<div className="confidence-badge">Overall Confidence: {result.overallConfidence}%</div>
<h3>Starters</h3>
<ul className="starters-list">
{result.starters.map((s, i) => (<li key={i}><div className="starter-header"><span className="slot">{s.slot}</span><span className="player-name">{s.name}</span><span className="confidence">{s.confidence}%</span></div><p className="reasoning">{s.reasoning}</p></li>))}
</ul>
<h3>FLEX</h3>
<div className="flex-choice"><div className="starter-header"><span className="slot">FLEX</span><span className="player-name">{result.flexChoice.name}</span><span className="confidence">{result.flexChoice.confidence}%</span></div><p className="reasoning">{result.flexChoice.reasoning}</p></div>
<h3>Bench</h3>
<ul className="bench-list">
{result.bench.map((b, i) => (<li key={i}><span className="player-pos">{b.position}</span><span className="player-name">{b.name}</span><p className="reasoning">{b.reasoning}</p></li>))}
</ul>
<h3>Positional Notes</h3>
<ul className="notes-list">{result.positionalNotes.map((n, i) => (<li key={i}><strong>{n.position}:</strong> {n.note}</li>))}</ul>
<div className="summary"><h3>Summary</h3><p>{result.summary}</p></div>
</div>
)}
What does this JSX render?
- An overall confidence badge showing Claude's certainty percentage.
- A starters list where each player shows their slot (QB1, RB1, etc.), name, confidence score, and reasoning.
- The FLEX pick highlighted separately with a golden border.
- Bench players with reasoning for why they are sitting.
- Positional notes and an overall summary paragraph.
- Save App.tsx.
- Now open client/src/App.css and add the following styles for the results panel above the @media query at the bottom:
h2 { font-size: 1.25rem; margin-bottom: 1rem; }
h3 { font-size: 1rem; margin: 1.5rem 0 0.5rem; }
.form-group { margin-bottom: 1rem; }
.form-group label { display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem; }
.form-group select, .add-player input, .add-player select { padding: 0.5rem; border: 1px solid var(--border); border-radius: 6px; font-size: 0.875rem; }
.add-player { display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
.add-player input:first-child { flex: 1; min-width: 120px; }
.add-player input:nth-child(3) { flex: 2; min-width: 200px; }
.add-player button { padding: 0.5rem 1rem; background: var(--primary); color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: 500; }
.add-player button:hover { background: var(--primary-dark); }
.optimize-btn:hover:not(:disabled) { opacity: 0.9; }
.optimize-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.confidence-badge { background: #e8f5e9; color: var(--success); padding: 0.5rem 1rem; border-radius: 20px; display: inline-block; font-weight: 600; margin-bottom: 1rem; }
.starters-list, .bench-list, .notes-list { list-style: none; }
.starters-list li, .bench-list li { padding: 0.75rem; border: 1px solid var(--border); border-radius: 8px; margin-bottom: 0.5rem; }
.starter-header { display: flex; align-items: center; gap: 0.5rem; }
.slot { background: var(--primary); color: white; padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; min-width: 40px; text-align: center; }
.confidence { margin-left: auto; font-weight: 600; color: var(--success); }
.reasoning { font-size: 0.8rem; color: var(--text-secondary); margin-top: 0.25rem; }
.flex-choice { padding: 0.75rem; border: 2px solid var(--warning); border-radius: 8px; }
.notes-list li { padding: 0.5rem 0; border-bottom: 1px solid var(--border); font-size: 0.875rem; }
.summary { margin-top: 1.5rem; padding: 1rem; background: var(--bg); border-radius: 8px; }
What do these styles do?
These rules style the results panel: the green confidence badge, card-like starter entries with slot labels, the golden-bordered FLEX pick, and the summary section.
They also add form polish (heading sizes, input sizing, button hover states) that makes the form panel look complete.
- Save App.css.
- Check your browser at http://localhost:5173. Vite auto-reloads the page. You should see the roster form with improved styling on the left and the empty results panel on the right.
Styles not appearing?
- Make sure you added the new rules above the @media query, not after it.
- Check that you saved both App.tsx and App.css.
Help me debug the styling issue
Test the full optimization flow
Both halves are wired up. Time to test the complete loop from roster entry to structured AI response.
- In your browser at http://localhost:5173, add the following players to your roster using the form:
- Patrick Mahomes (QB) with context: vs weak secondary.
- Derrick Henry (RB) with context: projected 16.2 points.
- Saquon Barkley (RB) with context: vs top rush defense.
- Ja'Marr Chase (WR) with context: target leader, PPR monster.
- CeeDee Lamb (WR) with context: questionable with ankle.
- Travis Kelce (TE) with context: consistent floor.
- Chris Olave (WR) with context: boom/bust, favorable matchup.
- David Montgomery (RB) with context: steady 12-point floor.
Before you click the button, predict: will you get back a structured lineup with confidence scores and slot assignments, or an error?
- Click Optimize Lineup.
After a few seconds, the results panel populates with your structured lineup. You should see starters slotted into QB1, RB1, RB2, WR1, WR2, and TE1 with confidence percentages and reasoning for each pick.
The FLEX choice appears in its own golden-bordered section. Bench players show why they are sitting, and positional notes give additional context.
Seeing an error in the results panel?
- Check that your ANTHROPIC_API_KEY environment variable is set in the terminal where the server is running.
- Confirm the server terminal shows no crash. If you see API error in the server logs, the key may be missing or invalid.
- If you see a CORS error in the browser console, make sure cors() middleware is applied before the route in index.ts.
Help me debug the frontend-backend connection
✔️ Awesome, I've got everything!
Great. Make sure both files are saved and your full flow is working before moving on.
ⓧ I'd like to double check the full code
Your server/src/index.ts should have the three sections you pasted above in order: imports and validation at the top, then the system and user prompts, then the try/catch API call with the listener at the bottom.
Your client/src/App.tsx should have the {result && (...)} block between the error div and the placeholder paragraph in the results panel.
Your client/src/App.css should have all the new style rules added above the @media query, including .confidence-badge, .starters-list, .flex-choice, and .summary.
The full loop is live. Your React form sends the roster to Express, Express validates it with Zod, and Claude returns a guaranteed-schema lineup via zodOutputFormat().
Next up, you will tackle the Secret Mission and extend this schema with waiver wire targets and a backup lineup.
Secret mission
Add Waiver Wire Targets and Backup Lineup
Your optimizer handles the happy path. But what about roster weaknesses? Extend the Zod schema with waiver wire recommendations and a backup lineup plan to learn how structured outputs scale when you add nested objects to an existing schema.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing cloud costs. The only expense is Anthropic API usage when you actively click "Optimize Lineup".
Resources you used:
- The lineup-optimizer project folder on your Desktop (contains server/ and client/ directories with all source code and dependencies).
- Anthropic API usage (~$0.05-0.15 consumed during development, charged per request only when you use the optimizer).
Keep everything running
No action needed. Choose this if you are still actively building or want to keep experimenting with different rosters and schema extensions.
- Your project files remain in ~/Desktop/lineup-optimizer.
- You will only incur Anthropic API costs when you actively run the optimizer (roughly $0.01-0.02 per request at introductory Sonnet 5 pricing).
- The Express server and Vite dev server only run when you start them manually. They do not run in the background.
Pause - I'll come back to this later
Shut down any running processes to free up terminal sessions and memory. Your files stay intact for next time.
- In any terminal running the Express server or Vite dev server, press Ctrl+C to stop the process.
- Close the terminal windows.
- Your project folder and all code remain on your Desktop. Next time, just cd back into the server or client directory and run the dev commands again.
Delete - I don't want to use this again
Remove all project files from your machine. This is permanent.
- Stop any running servers by pressing Ctrl+C in their terminal windows.
- Delete the project folder. On macOS/Linux, run:
rm -rf ~/Desktop/lineup-optimizer
On Windows, run:
rmdir /s /q "$env:USERPROFILE\Desktop\lineup-optimizer"
- Confirm the folder is gone by running:
ls ~/Desktop | grep lineup
You should see no output, confirming the folder is deleted.
What about my Anthropic API key?
Your ANTHROPIC_API_KEY environment variable was set up before this project. Keep it if you plan to use the Anthropic API for other projects. If you want to revoke access entirely, delete the key from your Anthropic console at console.anthropic.com.
Nice Work!
Nice Work!
You just built a full-stack fantasy football lineup optimizer that gets guaranteed-schema JSON from Claude using Zod. No more hoping raw text parses correctly.
You've learned how to:
- Define Zod schemas that enforce domain rules (position slots, no duplicates, FLEX constraints) and use zodOutputFormat() with client.messages.parse() to get typed parsed_output from Claude.
- Build a full-stack TypeScript app with a React frontend and Express backend that communicates structured AI responses end-to-end.
- Render AI-optimized lineups with per-player confidence scores, reasoning, positional notes, and a summary in a responsive React UI.
- Secret Mission: Extended the schema with waiver wire targets and backup lineup options to evolve structured outputs without breaking existing functionality.
Ready to quiz yourself?