Project case study

Netflix GPT

A Netflix-style streaming UI where you describe the kind of movie you want, and GPT finds it.

React 19ViteTailwind CSS v4React Router 7React IconsRedux ToolkitReact ReduxFirebase AuthenticationOpenAI APITMDB APIYouTube embedVercel
Netflix GPT screenshot

Objective

What this project sets out to do

Browsing endless rows of titles is slow when you already know the feeling you want ("a feel-good Hindi comedy", "a mind-bending thriller like Inception"). Netflix GPT recreates the familiar Netflix browsing experience and adds a natural-language search: GPT turns the request into movie names, and TMDB turns those names into real titles with posters and details.

  • Recreate the Netflix browse experience: an auto-playing trailer hero and scrollable rows of real, up-to-date movies.
  • Let users search by meaning, not just by title, using GPT as a recommendation engine.
  • Protect the app behind real authentication, with sign-up, sign-in and sign-out.
  • Keep it fast: lazy-loaded routes, cached API data and skeleton loaders instead of blank screens.

Features

What you can do in the app

  • Authentication with Firebase: sign up (name and avatar saved to the profile), sign in, sign out, with email and strong-password validation.
  • Protected routes: /browse and movie pages redirect to login when signed out, and the login page is skipped when already signed in.
  • Hero section: the top "Now Playing" movie's trailer auto-plays (muted, with a mute toggle) behind its title and overview.
  • Movie rows: Now Playing, Popular, Top Rated and Upcoming, live from the TMDB API.
  • GPT search: describe what you want in plain words; GPT suggests 5 movies, and each is matched to real TMDB results.
  • Multi-language search UI: English, Hindi and Spanish.
  • Movie details page: overview, top 10 cast, YouTube trailer and similar movies.
  • Shimmer loaders for every section, and a friendly 404 page.

Tech stack

What it's built with, and why

Frontend

React 19
Component-based UI with hooks; custom hooks hold all the data fetching.
Vite
Fast dev server and optimized production builds with code splitting.
Tailwind CSS v4
Utility-first styling for a responsive, Netflix-like dark UI.
React Router 7
createBrowserRouter with lazy-loaded routes and a protected-route wrapper.
React Icons
Lightweight icon set.

State management

Redux Toolkit
configureStore and createSlice - five focused slices with Immer-powered reducers.
React Redux
Provider, useSelector and useDispatch connect components to the store.

Services and APIs

Firebase Authentication
Email/password accounts, profile updates and the onAuthStateChanged session listener.
OpenAI API
Chat Completions turns a free-text request into five movie names.
TMDB API
Movie lists, search, details, credits, videos and similar titles.
YouTube embed
Background and detail-page trailers.

Hosting

Vercel
Production hosting with a SPA rewrite so deep links like /browse/movie/:id work.

State management

How state is managed with Redux Toolkit

Global, shared data lives in a single Redux store split into five slices, one per concern. Components never fetch directly: custom hooks fetch from the APIs and dispatch into the store, and components only read with useSelector. Purely local UI state (form inputs, the "auth checked" flag, mute toggle) stays in the component with useRef and useState.

The store

// utils/appStore.js
const appStore = configureStore({
  reducer: {
    user: userReducer,               // who is signed in
    movies: moviesReducer,           // browse rows + hero trailer
    gpt: gptReducer,                 // GPT search view and results
    config: configReducer,           // UI language
    movieDetails: movieDetailsReducer, // the open movie page
  },
});

// App.jsx
<Provider store={appStore}>
  <Body />
</Provider>

The slices

user

null when signed out, otherwise { uid, email, displayName, photoURL }.

addUserremoveUser
Written by:
The Firebase onAuthStateChanged listener in Body.
Read by:
ProtectedRoute (redirect when null) and Header (avatar, sign-out button).

movies

nowPlayingMovies, popularMovies, topratedMovies, upcomingMovies and the hero trailerVideo.

addNowPlayingMoviesaddPopularMoviesaddTopratedMoviesaddUpcomingMoviesaddTrailerVideo
Written by:
Custom hooks: useNowPlayingMovies, usePopularMovies, useTopratedMovies, useUpcomingMovies, useMovieTrailer.
Read by:
MainContainer (hero), SecondaryContainer (rows).

gpt

showGptSearch, movieNames, movieResults, isLoading, errorMessage.

toggleGptSearchViewsetGptSearchLoadingsetGptSearchErroraddGptMovieResultresetGptSearch
Written by:
Header (toggle), GptSearchBar (loading, results, errors), Body (reset on sign-out).
Read by:
Browse (which view to show), GptSearchBar, GptMovieSuggestions.

config

lang - the selected UI language (en, hindi, spanish).

changeLanguage
Written by:
The language dropdown in Header.
Read by:
GptSearchBar for the placeholder and button text.

movieDetails

movieId, details, cast, trailerKey, similarMovies, isLoading, errorMessage.

startMovieDetailsFetchsetMovieDetailssetMovieDetailsError
Written by:
The useMovieDetails(movieId) hook.
Read by:
MovieDetails page.

How data flows

1. Authentication: one listener keeps the store in sync

Firebase is the source of truth for the session. A single onAuthStateChanged subscription mirrors it into the user slice, so sign-in, sign-up, sign-out and page refreshes all update the UI the same way. A local authChecked flag holds rendering until Firebase restores the session, so a signed-in user never sees a flash of the login page.

  1. Firebase auth

    sign in / sign out / page load

  2. onAuthStateChanged

    one subscription in Body

  3. dispatch

    addUser or removeUser + resetGptSearch

  4. user slice

    null or user object

  5. ProtectedRoute / Header

    useSelector re-renders

components/Body.jsx - subscribe once, clean up on unmount
useEffect(() => {
  const unsubscribe = onAuthStateChanged(auth, (user) => {
    if (user) {
      const { uid, email, displayName, photoURL } = user;
      dispatch(addUser({ uid, email, displayName, photoURL }));
    } else {
      dispatch(removeUser());
      dispatch(resetGptSearch()); // no stale search results for the next user
    }
    setAuthChecked(true);
  });

  return () => unsubscribe();
}, [dispatch]);

2. Browse data: custom hooks fetch once, the store caches

Each row has its own custom hook that fetches from TMDB and dispatches into the movies slice. The hook first checks the store and skips the request when the data is already there, so switching to GPT search and back - which unmounts the browse view - doesn't refetch everything.

  1. Browse mounts

    calls 4 hooks

  2. Store check

    already loaded? skip

  3. TMDB fetch

    now playing, popular...

  4. movies slice

    add*Movies

  5. Rows render

    shimmer until data

hooks/useNowPlayingMovies.js - the store doubles as a cache
const useNowPlayingMovies = () => {
  const dispatch = useDispatch();
  const nowPlayingMovies = useSelector((store) => store.movies.nowPlayingMovies);

  const getNowPlayingMovies = async () => {
    const data = await fetch("https://api.themoviedb.org/3/movie/now_playing?&page=1", OPTIONS);
    const json = await data.json();
    dispatch(addNowPlayingMovies(json.results));
  };

  useEffect(() => {
    !nowPlayingMovies && getNowPlayingMovies(); // only fetch if not cached
  }, []);
};

3. GPT search: an async flow with loading and error states

The search bar drives the whole request lifecycle through the gpt slice: set loading, ask GPT for five movie names, look all five up on TMDB in parallel with Promise.all, then store names and results together in one action. Every failure path dispatches a user-friendly error, and the input is disabled while a search is running.

  1. User query

    read via useRef

  2. setGptSearchLoading

    disable input, clear error

  3. OpenAI

    "5 movie names, comma separated"

  4. TMDB search ×5

    Promise.all in parallel

  5. addGptMovieResult

    names + results together

components/GptSearch/GptSearchBar.jsx (simplified)
dispatch(setGptSearchLoading(true));

try {
  const gptResults = await openAi.chat.completions.create({
    model: "gpt-5.4-mini",
    messages: [{ role: "user", content: gptQuery }],
  });

  const gptMovies = gptResults.choices[0].message.content
    .split(",")
    .map((movie) => movie.trim());

  // Look up all five movies at the same time
  const tmdbResults = await Promise.all(gptMovies.map(searchMovieTMDB));

  dispatch(addGptMovieResult({ movieNames: gptMovies, movieResults: tmdbResults }));
} catch {
  dispatch(setGptSearchError("Something went wrong while searching. Please try again."));
}

4. Movie details: ignoring stale responses

Opening a movie loads details, credits, videos and similar titles in parallel. If the user clicks a "similar movie" before the first page finishes loading, the slower, older response could overwrite the new movie. The reducers prevent this: every response carries its movieId, and the slice ignores any response that isn't for the movie currently open.

  1. Open /movie/:id

    startMovieDetailsFetch(id)

  2. 4 requests

    details, credits, videos, similar

  3. setMovieDetails

    payload includes movieId

  4. Reducer guard

    id matches current? apply

utils/movieDetailsSlice.js - the race-condition guard
startMovieDetailsFetch: (state, action) => {
  state.movieId = action.payload; // the movie that's open now
  state.isLoading = true;
  state.details = null;
  // ...reset cast, trailer, similar
},
setMovieDetails: (state, action) => {
  const { movieId, details, cast, trailerKey, similarMovies } = action.payload;
  if (state.movieId !== movieId) return; // stale response - ignore it
  state.details = details;
  state.cast = cast;
  state.trailerKey = trailerKey;
  state.similarMovies = similarMovies;
  state.isLoading = false;
},

Design decisions

  • Why Redux instead of Context? The user, the GPT toggle and the language are read in places far apart in the tree (header, browse page, search, protected routes). Redux Toolkit keeps that shared state in one predictable place with DevTools support, and useSelector re-renders only the components that read the changed value.
  • One slice per concern. user, movies, gpt, config and movieDetails change for different reasons, so each has its own small reducer and actions instead of one large object.
  • Fetching lives in custom hooks, not components. Components stay presentational: they read from the store and render. Hooks own the API calls and dispatch.
  • The store is the cache. Hooks check the slice before fetching, so data survives view switches without a separate caching library.
  • Explicit async states. isLoading and errorMessage are part of the gpt and movieDetails slices, which drive shimmer loaders, disabled inputs and friendly error messages.
  • Reset on sign-out. resetGptSearch returns the slice to its initial state, so the next user never sees the previous user's results.
  • Local state stays local. Form fields use useRef (no re-render per keystroke) and one-off UI flags use useState; only data shared across components goes into Redux.

See it in action

Try the live app or browse the full source on GitHub.