Build a Netflix Clone Website Using Antigravity

Build a Netflix Clone Using Antigravity — Unreal Fusion
Tutorial · Full-Stack

Build a Netflix Clone
Using Antigravity

18 min read
March 21, 2026
Firas · Unreal Fusion
Antigravity Python Frontend

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.

💡
What We're Building A Netflix-style streaming interface with a hero banner, scrollable movie rows, hover cards, a working search modal, and a dark/light theme toggle — all wired up with a lightweight Python backend.
🎬
Hero Banner
Cinematic full-width featured content with trailer play button and metadata overlay.
🎞️
Content Rows
Horizontally scrollable genre rows with hover-to-expand card interactions.
🔍
Search Modal
Live-search overlay powered by a Python FastAPI endpoint.
🔐
Auth Flow
Profile selection screen and JWT-based session management.
📡
TMDB API
Real movie data fetched from The Movie Database REST API.
Antigravity Core
Our backend is built on the "effortless by design" principle — zero bloat.

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.

1
Create your virtual environment
Always isolate your Python projects. This keeps your system clean and your dependencies reproducible.
2
Install the dependencies
FastAPI, Uvicorn (ASGI server), httpx for async HTTP calls to TMDB, and python-dotenv for managing API keys.
3
Get your TMDB API key
Sign up at themoviedb.org → Settings → API → Request a Developer key. It's free and instant.
4
Structure your project
Flat is better than nested — a single main.py, a static/ folder, and a .env file. That's all we need.
bash
# 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.

python — main.py
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)
Pro Tip The TMDB API returns paginated results. Our /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.

javascript — app.js
// 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.

css — style.css
.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;
}

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.

javascript — search module
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));
⚠️
Rate Limiting TMDB's free tier allows 50 requests per second. For a personal project this is fine, but if you plan to deploy publicly, consider caching responses in Redis with a 5-minute TTL to stay well within limits.

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.

bash
# 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
Free Deployment Alternatives Render.com and Fly.io both offer generous free tiers for FastAPI apps. Render auto-deploys from GitHub on every push — just connect your repo and you're done.

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:

  1. Add a video player — integrate YouTube trailers via the TMDB video endpoint and overlay them in a modal with <iframe>.
  2. User authentication — add JWT login with FastAPI-Users so people can create watchlists.
  3. Watchlist persistence — store user favorites in SQLite (via SQLModel) with a single table.
  4. Infinite scroll — on scroll-to-bottom of a row, fetch page 2 of that genre and append more cards.
  5. PWA support — add a manifest.json and 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.

🎬
Full Source Code The complete project is available on the Unreal Fusion GitHub. Drop a ⭐ if it helped you — it genuinely motivates more tutorials like this one.
Post a Comment (0)
Previous Post Next Post

Archive Pages Design$type=blogging$count=7