Today: lecture + demo (55 min) then hands-on lab (105 min)
These are not edge cases. They are the core requirements of your final project.
One file: app.py
UI + logic + data access all together.
Fast to prototype, hard to scale.
Presentation • Application • Data
Same responsibilities, separated for
scale, security, and team collaboration.
| Weeks 1–4 Artifact | → | Weeks 5+ Artifact |
|---|---|---|
| CSV schema / format contract | → | SQL schema + migrations |
| DataFrame assert | → | Row-level security (RLS) policy |
| st.metric() | → | React component |
| Scenario | Streamlit? | Next.js? |
|---|---|---|
| GIX study-space sensor dashboard — one researcher, read-only, no login | Yes | Over-engineering |
| Shared incident-tracking app — login required, per-user data | Fighting the framework | Yes |
| Quick prototype to test if users want a bookmark feature | Yes | Too much infra for a test |
Invest production infrastructure only after you have validated the idea.
def greet(name: str) -> str:
return f"Hello, {name}!"
user = {"name": "Alice", "age": 25}
print(user["name"])
numbers = [1, 2, 3]
doubled = [n * 2 for n in numbers]
function greet(name: string): string {
return `Hello, ${name}!`;
}
const user = { name: "Alice", age: 25 };
console.log(user.name);
const numbers = [1, 2, 3];
const doubled = numbers.map(n => n * 2);
def → function • Indentation → curly braces • f-strings → backtick template literals • List comprehension → .map()
import requests
response = requests.get(url)
data = response.json()
const res = await fetch(url);
const data = await res.json();
Key insight: async/await in TypeScript looks almost identical to Python. In web development, every API call and database query is asynchronous — the pattern becomes second nature fast.
function greet(user) {
return `Hello, ${user.name}`;
}
greet({ username: "Alice" });
// Returns "Hello, undefined"
// Silent bug at runtime
interface User {
name: string;
email: string;
}
function greet(user: User): string {
return `Hello, ${user.name}`;
}
greet({ username: "Alice" });
// ERROR at compile time!
// "Did you mean 'name'?"
An interface is a contract: "anything labeled as a User must have these fields." When AI generates code that passes the wrong shape, TypeScript catches it before you run anything.
// This interface says: every Todo from the database
// has exactly these fields.
export interface Todo {
id: string; // UUID from Supabase
user_id: string; // who owns this record
title: string; // the content
is_complete: boolean;
created_at: string;
}
When you see an interface, translate it as: "here is the shape of data that flows through this boundary." If AI accidentally calls todo.body instead of todo.title, TypeScript tells you right now — not when a user complains.
const name = "Ada"; // default: cannot reassign
let count = 0; // when reassignment is needed
Think Python: const ≈ a name that does not get rebound; let when you update the value.
// primitives
const n: number = 42;
const ok: boolean = true;
const label: string = "hi";
// array of strings
const tags: string[] = ["ui", "lab"];
interface Profile {
name: string;
email?: string; // optional — may be absent
}
// inline object type (common in callbacks)
function onSave(data: { title: string; id?: string }) { /* ... */ }
Reading order: types sit to the right of : on variables and parameters. The editor uses them to flag mistakes before you run the app.
type Status = "idle" | "loading" | "error";
function fmt(value: string | null): string {
if (value === null) return "";
// TS knows `value` is string here
return value.toUpperCase();
}
interface — named object shape; common as export interface Props. Often extended.
type — alias for unions, primitives, tuples: type ID = string;
Narrowing: after if (x === null) return;, TypeScript treats x as defined below. Same idea as checking response.ok before response.json().
Every interaction → entire script re-runs from top to bottom.
Simple, but cannot build real-time interactive UIs.
Only the affected component re-renders when its state changes.
const [todos, setTodos] = useState([]);
// setTodos(newData) → re-renders
// ONLY this component
useState declares reactive state. useEffect runs code after the component appears (like "on_load"). You don't need to master these — just recognize them in the starter kit.
Runs on the server before the browser receives HTML.
Cannot: useState, useEffect, onClick
"use client"; // ← this line matters
Runs in the user's browser.
Cannot: access server secrets safely
Rule of thumb: Add 'use client' only when you need useState, useEffect, or event handlers. Otherwise, keep it as a Server Component.
Standard tier names: Browser (Presentation) → Server (Application) → Database (Data). Arrows between tiers are boundaries. Mixing tiers = “framework mismatch” anti-pattern.
This course: use fetch to public HTTPS APIs or to your Next.js routes. Private keys stay in Server Components or API routes — same rule as “where would an agent live?”
curl "https://api.open-meteo.com/v1/forecast?\
latitude=47.65&longitude=-122.30&\
hourly=temperature_2m&forecast_days=1"
response = requests.get(url, params=params, timeout=10)
response.raise_for_status()
data = response.json()
# Three error cases — not one:
except requests.exceptions.Timeout:
# Server slow or unreachable
except requests.exceptions.HTTPError as e:
# Server replied with 4xx or 5xx
except requests.exceptions.ConnectionError:
# Network itself failed
Same defensive mindset as Week 3: external systems fail. raise_for_status() makes failures loud — a blank chart three interactions later is your enemy.
| Scenario | Status | What to Assert |
|---|---|---|
| Happy path | 200 OK | Response body matches expected schema |
| Auth failure | 401 | No data leaked, clear error message |
| Network error | timeout | UI shows fallback, no crash |
| Malformed input | 400 | Validation error returned, not a 500 |
// Your first integration test
const res = await fetch('/api/items', { method: 'GET' });
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toHaveProperty('items');
Your first integration test. Four scenarios, four assertions.
If closing Streamlit wipes your state, you need a database (or equivalent hosted storage) for multi-user or durable apps.
| Category | Examples | Typical use |
|---|---|---|
| Relational / SQL | PostgreSQL (Supabase), SQLite | Structured rows, joins, ACID, multi-user apps |
| Document | MongoDB | Flexible JSON-shaped documents, rapid iteration |
| Key-value / cache | Redis | Sessions, rate limits, speed |
| Vector | pgvector, dedicated vector DBs | Embeddings, semantic search (later weeks) |
Default to relational for durable state, relationships between entities, auth-linked rows, and shared access — aligned with the final project.
Use a connection URL and a client or SQL; evolve the schema with migrations. Next: Supabase = hosted Postgres + auth + RLS.
PostgreSQL via Supabase: the code below uses the same relational model as the previous slides. Supabase adds hosted Postgres, auth, and row-level security.
supabase = create_client(url, key)
# Read all bookmarks
result = supabase.table("bookmarks").select("*").execute()
# Insert a new bookmark
supabase.table("bookmarks").insert({
"url": "https://api.open-meteo.com",
"title": "Open-Meteo API Endpoint",
}).execute()
# Read again — one more row
result = supabase.table("bookmarks").select("*").execute()
The key demo: Kill the terminal. Count to three. Reopen. Data is still there. That is persistence — the gap between Streamlit and a production web application.
API_KEY = "sk-proj-abc123xyz789"
requests.get(url,
params={"api_key": API_KEY})
api_key = os.environ["OPENAI_API_KEY"]
requests.get(url,
headers={"Authorization":
f"Bearer {api_key}"})
Three snippets. Each has a different security bug. 30–35 seconds per snippet.
API_KEY = "sk-proj-abc123xyz789def456"
response = requests.get(
"https://api.openai.com/v1/models",
params={"api_key": API_KEY}
)
Hardcoded key + key in URL params (appears in server logs)
except Exception as e:
return jsonify({"error": str(e)}), 500
str(e) leaks database hostname, project URL, and connection params to the client
'use client' // ← runs in the browser
export default function Dashboard() {
const fetchData = async () => {
const res = await fetch(
`https://api.example.com/data?key=${process.env.NEXT_PUBLIC_SECRET_KEY}`
);
return res.json();
};
}
NEXT_PUBLIC_ means "compile into the client JavaScript bundle." Anyone can open DevTools → Sources and find it in 30 seconds.
The rule: If the variable is a secret, it lives in a Server Component, API Route, or Server Action. It never gets a NEXT_PUBLIC_ prefix.
If you skip the spec, the AI populates the right column with guesses. Some will be correct. Some will create security vulnerabilities.
"A structural engineer does not pick the paint color. An interior designer does not specify the load-bearing wall dimensions. Claude Code is the structural engineer. Cursor is the interior designer. You are the architect."
Write the spec before touching any AI tool. The 40% planning determines whether the 20% coding produces the right thing.