Build a Netflix Clone
Using Antigravity
Antigravity is Python's most delightfully absurd easter egg — but what if we took it seriously? In this tutorial, we'll build a fully functional Netflix-style streaming UI using Python, Antigravity's philosophy of effortless flight, and a dash of creative madness.
01 / What Is Antigravity?
If you've never typed import antigravity into a Python REPL, you're missing one of the language's most charming secrets. Guido van Rossum's team hid it as a homage to the famous XKCD comic — running it opens a browser to "Python: How to fly".
But beyond the joke, Antigravity represents a core Python philosophy: simplicity should be the default. You shouldn't need 400 lines of boilerplate to get something off the ground. This tutorial builds an entire Netflix-clone stack by embracing that ethos — minimal configuration, maximum functionality.
02 / Project Setup
We'll keep dependencies razor-thin. The entire backend runs on FastAPI + Uvicorn, with TMDB doing the heavy lifting for movie data. The frontend is vanilla HTML/CSS/JS — no React, no build step, no npm headaches.
main.py, a static/ folder, and a .env file. That's all we need.# Create and activate a virtual environment
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# Install all dependencies
pip install fastapi uvicorn httpx python-dotenv antigravity
# Project structure
netflix-clone/
├── main.py
├── .env
└── static/
├── index.html
├── style.css
└── app.js
03 / The Python Backend
Our backend is three responsibilities: serve static files, proxy TMDB API calls (to keep our API key secret), and handle a simple search endpoint. The import antigravity at the top is ceremonial — but it sets the tone.
import antigravity # We fly now 🚀
import httpx
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from dotenv import load_dotenv
import os
load_dotenv()
app = FastAPI(title="NetflixClone", docs_url=None)
app.mount("/static", StaticFiles(directory="static"), name="static")
TMDB_KEY = os.getenv("TMDB_API_KEY")
TMDB_BASE = "https://api.themoviedb.org/3"
async def tmdb_get(path: str, **params) -> dict:
"""Thin TMDB proxy — keeps API key server-side."""
async with httpx.AsyncClient() as client:
r = await client.get(
f"{TMDB_BASE}{path}",
params={"api_key": TMDB_KEY, "language": "en-US", **params}
)
r.raise_for_status()
return r.json()
@app.get("/")
async def root():
return FileResponse("static/index.html")
@app.get("/api/trending")
async def trending():
return await tmdb_get("/trending/movie/week")
@app.get("/api/genre/{genre_id}")
async def by_genre(genre_id: int, page: int = 1):
return await tmdb_get(
"/discover/movie",
with_genres=genre_id,
sort_by="popularity.desc",
page=page
)
@app.get("/api/search")
async def search(q: str):
if not q.strip():
raise HTTPException(400, "Query cannot be empty")
return await tmdb_get("/search/movie", query=q)
/api/genre/{id} endpoint passes the page param so the frontend can implement infinite scroll with a simple fetch-on-scroll listener.
04 / Frontend Architecture
The frontend is deliberately framework-free. We're targeting the Antigravity principle: the simplest possible implementation that still feels premium. Here's how we structure app.js:
The Row Component
Each genre row is a standalone module that fetches its own data. No global state manager needed — just a buildRow() function that returns a DOM element.
// Genre IDs we want to display as rows
const GENRES = [
{ id: 28, label: "Action" },
{ id: 35, label: "Comedy" },
{ id: 27, label: "Horror" },
{ id: 10749, label: "Romance" },
{ id: 878, label: "Sci-Fi" },
{ id: 16, label: "Animation" },
];
const IMG_BASE = "https://image.tmdb.org/t/p/w342";
async function buildRow({ id, label }) {
const { results } = await fetch(`/api/genre/${id}`).then(r => r.json());
const section = document.createElement("section");
section.className = "row";
section.innerHTML = `
<h2 class="row-title">${label}</h2>
<div class="row-track">
${results.slice(0, 20).map(movie => `
<div class="card" data-id="${movie.id}"
style="--bg: url('${IMG_BASE}${movie.poster_path}')">
<div class="card-overlay">
<span class="card-title">${movie.title}</span>
<span class="card-rating">★ ${movie.vote_average.toFixed(1)}</span>
<button class="card-play" onclick="openModal(${movie.id})">▶ Play</button>
</div>
</div>
`).join("")}
</div>
`;
return section;
}
async function init() {
await loadHero();
for (const genre of GENRES) {
const row = await buildRow(genre);
document.getElementById("rows").appendChild(row);
}
}
document.addEventListener("DOMContentLoaded", init);
Hover Cards with CSS Only
The Netflix-style "scale-and-reveal" hover effect on cards doesn't need JavaScript at all. Pure CSS transitions handle it beautifully, with z-index and transform: scale() doing the heavy lifting.
.card {
width: 180px;
aspect-ratio: 2/3;
background-image: var(--bg);
background-size: cover;
background-position: center;
border-radius: 4px;
flex-shrink: 0;
position: relative;
cursor: pointer;
transition: transform 0.3s cubic-bezier(.25, .46, .45, .94),
z-index 0s 0.3s;
}
.card:hover {
transform: scale(1.35);
z-index: 100;
transition: transform 0.3s cubic-bezier(.25, .46, .45, .94),
z-index 0s 0s;
}
.card-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0,0,0,0.95) 0%, transparent 60%);
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 12px;
opacity: 0;
transition: opacity 0.25s;
border-radius: 4px;
}
.card:hover .card-overlay {
opacity: 1;
}
05 / Live Search Modal
The search experience is a full-screen overlay with debounced input — we wait 300ms after the user stops typing before hitting the backend. This avoids hammering the API with every keystroke.
let debounceTimer;
function debounce(fn, delay) {
return (...args) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => fn(...args), delay);
};
}
const handleSearch = debounce(async (query) => {
if (query.length < 2) return;
const data = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
.then(r => r.json());
renderSearchResults(data.results.slice(0, 8));
}, 300);
document.getElementById("search-input")
.addEventListener("input", (e) => handleSearch(e.target.value));
06 / Running & Deploying
Local development is a single command. For deployment, we'll use Railway — it detects the main.py FastAPI app automatically and gives you a public URL in under 2 minutes.
# Development — hot reload enabled
uvicorn main:app --reload --port 8000
# Open http://localhost:8000 and you're live 🎬
# Deploy to Railway
npm install -g @railway/cli
railway login
railway init
railway up
# Set your env variable
railway variables set TMDB_API_KEY=your_key_here
07 / What's Next?
You've built a fully functional Netflix clone with a Python backend, real TMDB data, scrollable genre rows, hover cards, and live search. Here's where to take it further:
- Add a video player — integrate YouTube trailers via the TMDB video endpoint and overlay them in a modal with
<iframe>. - User authentication — add JWT login with FastAPI-Users so people can create watchlists.
- Watchlist persistence — store user favorites in SQLite (via SQLModel) with a single table.
- Infinite scroll — on scroll-to-bottom of a row, fetch page 2 of that genre and append more cards.
- PWA support — add a
manifest.jsonand service worker to make it installable on mobile.
The Antigravity spirit holds throughout: every feature should feel effortless to implement, because complexity that doesn't serve the user is just turbulence. Keep it simple, keep it flying.
