A separate notebook for the project phase — building a real Netflix-style app with auth, forms, and GPT search. Each build log captures the decisions, the structure, and the interview-gold tradeoffs, not the live-coding fumbles.
The project is a Netflix-style streaming app with a twist: a GPT-powered search that recommends movies from a plain-English prompt. It's chosen because it forces you through features a foundations course usually skips — real auth, real forms, and a third-party AI API.
A backend-as-a-service (Firebase) handles sign-up/sign-in, so the browse page is reachable only when logged in.
Hand-rolled form handling and validation — the bread-and-butter of almost every app.
A search box that sends a natural-language query to a GPT API and renders the suggested titles.
Instead of wiring a bundler by hand, this project starts from a scaffold — a ready-made folder structure, dev server, and tooling generated by one command. Create React App (CRA) gives you that out of the box.
// one command generates the whole project npx create-react-app netflix-gpt cd netflix-gpt npm start // dev server + hot reload, opens the browser
You install and configure the bundler, testing library, Babel, etc. Maximum control, maximum setup time. Good to understand once.
One command sets up Webpack (the bundler), Jest + React Testing Library, and npm scripts (start, build, test). You start building immediately.
The CRA starter ships a demo page (spinning logo, sample CSS). Delete the boilerplate JSX, logo import, and the contents of App.css / index.css before building — start from an empty canvas.
Tailwind is a utility-first CSS toolkit — you style by composing tiny class names (text-3xl, font-bold, bg-red-700) right in the markup instead of writing separate CSS rules. Setup in a CRA project is three small steps.
// 1. install + generate config npm install -D tailwindcss npx tailwindcss init // 2. tailwind.config.js - tell it which files to scan content: ["./src/**/*.{js,jsx,ts,tsx}"], // 3. index.css - pull in Tailwind's layers @tailwind base; @tailwind components; @tailwind utilities;
content path is not optional: Tailwind scans those files to know which classes you actually use, then ships only those (keeps the CSS tiny). Miss this and your styles won't apply.Plan before building. The app is two real pages plus an overlay feature, and the routing rule encodes the auth story: the browse page exists only for logged-in users; everyone else lands on login.
Login page — sign-in / sign-up form. Default landing.
Browse page — header, hero trailer, movie rows. Auth-only.
Search overlay on browse — prompt in, movie suggestions out.
Component structure stays hierarchical so App.js stays a thin root. Components live in /components; shared helpers and constants in /utils.
// App.js - thin root, only sets up routing const appRouter = createBrowserRouter([ { path: "/", element: <Login /> }, { path: "/browse", element: <Browse /> }, ]); const App = () => <RouterProvider router={appRouter} />;
/components for UI pieces (Login, Header, Browse), /utils for shared logic (validation, constants, config). Keeps the tree predictable as it grows.App.js and nothing else — every real screen is its own component mounted by the router.createBrowserRouter defines the path→element map; RouterProvider makes it live. (Same API as the foundations notes.)This error means a component is recursively rendering itself (e.g. Body referencing Body). Each route element must point to the right screen — / → Login, not back into the router's own parent.
Sign-in and sign-up share almost everything, so don't build two forms — build one and flip it with a boolean state variable. This is the reusable idea: a single component whose UI changes based on state.
const [isSignIn, setIsSignIn] = useState(true); const toggleForm = () => setIsSignIn(!isSignIn); // title + button text react to the same flag <h1>{isSignIn ? "Sign In" : "Sign Up"}</h1> // extra field only in sign-up mode {!isSignIn && <input type="text" placeholder="Full Name" />} <button>{isSignIn ? "Sign In" : "Sign Up"}</button> <p onClick={toggleForm}> {isSignIn ? "New here? Sign up now" : "Already registered? Sign in"} </p>
!prev: setIsSignIn(!isSignIn) flips the flag, and the whole form re-renders in the other mode — one state drives title, button, link text, and which fields show.&&: sign-up adds a name field; render it only when !isSignIn. No second form, no duplication.To validate on submit, you need the values inside the inputs. Two ways: bind each field to state (controlled), or grab the field's value directly via a ref. For a submit-only read, a ref is lighter — no re-render on every keystroke.
const email = useRef(null); const password = useRef(null); <input ref={email} type="text" placeholder="Email" /> <input ref={password} type="password" placeholder="Password" /> const handleButtonClick = () => { // reach the DOM node's live value const e = email.current.value; const p = password.current.value; };
The input owns its value; you read ref.current.value only when needed. No re-render per keypress. Great for read-on-submit forms.
React owns the value; every keystroke updates state and re-renders. Needed when the UI must react live to typing (e.g. instant validation, disabled buttons).
useRef(null) returns a mutable box; attach it with ref={email} and React points email.current at the real DOM node. .current.value is the live input text.A ref is a coat-check ticket for a DOM element. React hands you a numbered stub (email.current); whenever you want, you walk up and read what's there (.value) — without disturbing anyone in the room. State is a live scoreboard: every change is announced to the whole room (a re-render). Use the ticket when you only need to peek occasionally; use the scoreboard when everyone must see each change instantly.
Validation logic doesn't belong inside the component — it bloats it. Put it in a pure helper in /utils that takes the values and returns an error message (or null for "all good"). The component stays a one-liner at the call site.
// utils/validate.js export const checkValidData = (email, password) => { const isEmailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); const isPwValid = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/.test(password); if (!isEmailValid) return "Email ID is not valid"; if (!isPwValid) return "Password is not valid"; return null; // null = no errors };
// Login.js - validate on submit, show the message const [errorMessage, setErrorMessage] = useState(null); const handleButtonClick = (e) => { e.preventDefault(); // stop the form's page reload const msg = checkValidData( email.current.value, password.current.value ); setErrorMessage(msg); // renders the red text, or clears it if (msg) return; // only proceed when valid // ...sign in / sign up here };
e.preventDefault(): a button inside a <form> submits it by default, which reloads the page. This is plain web behavior, not a React quirk — prevent it so your handler runs instead.regex.test(value) returns true/false. Email checks for name@domain.tld; password here wants 8+ chars with upper, lower, and a digit.null on success. The component just stores it in state — truthy shows the error, null clears it.useState; the input values are read once on click, so they're refs. Clean division of the two hooks.A <button> in a form triggers submit → full reload. Call e.preventDefault() in the handler to stop it.
You read email.value instead of email.current.value. The ref is a box; the node lives on .current.
The content glob in the config doesn't match your files, so Tailwind purges everything. Point it at ./src/**/*.
Use absolute for the background, a higher z-index for the header, so the form and nav sit above the hero image.
Typing rafce in a JS/JSX file scaffolds a full arrow-function component with export — saves writing the boilerplate.
Keeping checkValidData pure (inputs in, message out, no side effects) makes it trivial to reuse and unit-test.
useRef gives you a mutable box whose .current can point at a DOM node or hold any value across renders — and changing it does not trigger a re-render. Use it to read an input's value on submit or to reference a DOM element; use state when a change must update the UI.isSignIn). The flag drives the title, button text, link text, and which fields render. A toggle handler flips it with setIsSignIn(!isSignIn), re-rendering the form in the other mode.<form> triggers the browser's native submit, which reloads the page. It's standard web behavior, not React-specific. Call e.preventDefault() at the top of your click/submit handler so your code runs instead.null. Often you test with regex — e.g. regex.test(email) for format. The component just calls it on submit and stores the result in state to show or clear the error./browse is protected: a logged-out user who tries it gets redirected to the login page (/). The router maps paths to screens, and the auth check decides whether the user is allowed through./components holds UI pieces, /utils holds shared, non-visual logic like validation, constants, and config. Pulling logic out of components keeps them small, focused, and easier to reuse and test.Auth works (sign-in / up / out exist), but two holes remain. A logged-out user can still open /browse directly, and a logged-in user landing on the login page should be bounced to browse. Both need the app to react to auth state, not just set it once.
Typing the /browse URL with no user shows private content. It must redirect to login.
A signed-in user on / should be sent straight to /browse — the way real Netflix behaves.
Firebase already gives the signal: onAuthStateChanged is a listener that fires whenever the user logs in or out. The fix is about where you run it.
A redirect can only happen from a component inside the router (otherwise useNavigate throws "may only be used in the context of a Router"). The header is mounted on every page and lives inside the router — so it's the one right home for the auth listener.
// Header.js - the always-on auth brain useEffect(() => { const unsubscribe = onAuthStateChanged(auth, (user) => { if (user) { dispatch(addUser({ uid: user.uid, email: user.email })); navigate("/browse"); // logged in -> browse } else { dispatch(removeUser()); navigate("/"); // logged out -> login } }); return () => unsubscribe(); // cleanup on unmount }, []);
navigate("/browse") from the login handler and the navigate("/") from sign-out. The listener owns all routing now — single source of truth.RouterProvider sits outside the routes, so its useNavigate has no router context. The header is a child of the routes, so it's safe.The auth listener is a doorman in the lobby — one person, always on duty, who every guest passes. Instead of putting a separate bouncer at each floor (a navigate in every component), the doorman checks your badge once and sends you to the right floor. Put the doorman in a room that's always open (the header), and the whole building is covered.
A listener that's never removed is a leak. Each time the header mounts, onAuthStateChanged attaches another subscription; without cleanup they pile up. onAuthStateChanged returns an unsubscribe function — call it from the effect's cleanup so the old subscription is torn down on unmount.
return from a useEffect runs when the component unmounts (and before the effect re-runs). Returning () => unsubscribe() detaches the Firebase listener cleanly.componentWillUnmount in class components.Stop hard-coding strings (logo URLs, avatar URLs, API option objects) inline. Put them in a single utils/constants.js and import them where needed — change once, reflected everywhere.
// utils/constants.js export const LOGO = "https://.../netflix-logo.png"; export const USER_AVATAR = "https://.../default-avatar.png"; export const API_OPTIONS = { method: "GET", headers: { accept: "application/json", Authorization: "Bearer <TMDB_ACCESS_TOKEN>", }, };
src={USER_AVATAR}) — a leftover wrapper like {`${USER_AVATAR}`} can produce an invalid URL (the real cause of a Firebase "invalid photoURL" 400 in this build).Movie data comes from TMDB (The Movie Database) — register an app, get an access token, and read the docs to find the right endpoint. The "now playing" list is a GET call that needs your token passed in the fetch options.
const getNowPlayingMovies = async () => { const data = await fetch( "https://api.themoviedb.org/3/movie/now_playing?page=1", API_OPTIONS // carries the Bearer token ); const json = await data.json(); // json.results = array of 20 movies };
API_OPTIONS object (with the Authorization: Bearer ... header) goes into every fetch — that's why it lives in constants.results array holds the movies; each item has original_title, overview, vote_average, etc. That's what the UI will read.CRA wraps the app in <React.StrictMode>, which intentionally double-invokes effects in development to surface impurities. It only happens locally, never in a production build — it's a feature, not a bug. (You can remove StrictMode while debugging, but keep it.)
User data already lives in Redux; movie data deserves its own slice rather than being crammed into the user slice. Create a moviesSlice with an action per category, then dispatch the fetched results into it.
// utils/moviesSlice.js const moviesSlice = createSlice({ name: "movies", initialState: { nowPlayingMovies: null, popularMovies: null }, reducers: { addNowPlayingMovies: (state, action) => { state.nowPlayingMovies = action.payload; }, addPopularMovies: (state, action) => { state.popularMovies = action.payload; }, }, });
state.user for the person, state.movies for content. Each slice plugs into the store's reducer object independently.dispatch(addNowPlayingMovies(json.results)) writes the 20 movies into the store; components then read them with a selector."Fetch from TMDB → dispatch into the store" is logic, not UI. Pulling it into a custom hook keeps the Browse component to just render code. A hook is just a function whose name starts with use and that uses other hooks inside.
// hooks/useNowPlayingMovies.js const useNowPlayingMovies = () => { const dispatch = useDispatch(); useEffect(() => { const getNowPlayingMovies = async () => { const data = await fetch(NOW_PLAYING_URL, API_OPTIONS); const json = await data.json(); dispatch(addNowPlayingMovies(json.results)); }; getNowPlayingMovies(); }, []); }; export default useNowPlayingMovies;
// Browse.js - now clean const Browse = () => { useNowPlayingMovies(); // one line does the fetch + store update return ( <div> <Header /> <MainContainer /> <SecondaryContainer /> </div> ); };
/hooks directory keeps custom hooks separate from components and utils — predictable structure as the app grows.use prefix is required: it tells React (and the lint rules) this function may call hooks, so the Rules of Hooks apply. It's a convention with teeth, not just style.usePopularMovies (and top-rated, upcoming) trivial — swap the endpoint and the dispatch action. Each movie row gets its own hook.Plan the layout before coding it. The browse page splits into two regions, and each breaks down further — so every piece becomes a small, focused component.
The hero: VideoBackground (trailer) + VideoTitle (name, overview, play buttons).
Stacked MovieLists (Now Playing, Popular…), each a row of MovieCards.
Components read what they need from the store with useSelector. The hero only needs one movie, so it takes the first of the list — guarding against the brief moment before data arrives.
const MainContainer = () => { const movies = useSelector((store) => store.movies?.nowPlayingMovies); if (!movies) return null; // early return: data not ready const mainMovie = movies[0]; // hero = first movie const { original_title, overview } = mainMovie; // pass title + overview down to VideoTitle };
store.movies?.nowPlayingMovies won't crash if movies is briefly undefined during the first render.if (!movies) return null skips rendering until the store is populated — otherwise movies[0] throws on null. A clean, common pattern."may only be used in the context of a Router." The component must live inside the routes — use the header, not the router's parent.
Re-mounting re-subscribes onAuthStateChanged. Return its unsubscribe from the effect to tear it down.
StrictMode double-invokes effects in dev only. Don't "fix" it by deleting StrictMode in production code.
The store is null before the fetch resolves. Guard with optional chaining + an early return.
Endpoint, method, and auth header all come from TMDB's reference — learn to read docs rather than copy snippets blindly.
Cloning the now-playing hook for each category keeps fetching logic out of components and trivially repeatable.
onAuthStateChanged listener in the always-mounted header sends logged-out users to / and logged-in users to /browse. Centralizing it means every route is guarded automatically, with no per-component checks.unsubscribe so the auth listener doesn't leak or duplicate.use) that bundles hook-based logic. Extracting the "fetch movies and dispatch to store" logic into useNowPlayingMovies keeps the Browse component to pure render code, and makes the same pattern trivial to reuse for other categories.store.movies?.nowPlayingMovies) so the read is safe, and an early return (if (!movies) return null) so the component renders nothing until the data arrives — avoiding errors like reading index [0] of null.user for the logged-in person, movies for content. Each slice has its own actions and reducer and plugs into the store independently, which scales better than overloading one slice.Promise.all, and keeping secrets out of the client.GPT search is a separate view, not a bar crammed into the header. A button in the header flips between the normal browse page and the search page. The on/off flag lives in Redux — consistent with everything else in this app, which uses the store instead of local useState.
// gptSlice.js - just a toggle const gptSlice = createSlice({ name: "gpt", initialState: { showGptSearch: false }, reducers: { toggleGptSearchView: (state) => { state.showGptSearch = !state.showGptSearch; }, }, });
// Browse.js - conditional render const showGptSearch = useSelector((s) => s.gpt.showGptSearch); return showGptSearch ? <GptSearch /> : ( <> <MainContainer /> <SecondaryContainer /> </> );
<>...</> fragment — no extra DOM node.A small, high-impact addition: drive every visible string from a language config instead of hard-coding it, then let a dropdown switch the active language. Store the chosen language in Redux and read it in each component.
// utils/languageConstants.js const lang = { en: { search: "Search", gptSearchPlaceholder: "What would you like to watch today?" }, hindi: { search: "खोजें", gptSearchPlaceholder: "आज आप क्या देखना चाहेंगे?" }, spanish: { search: "Buscar", gptSearchPlaceholder: "¿Qué te gustaría ver hoy?" }, }; export default lang;
// read the active language from the store, then index in const langKey = useSelector((s) => s.config.lang); <button>{lang[langKey].search}</button>
{ identifier, name } so adding a language is one entry, not edits in two places.Get an API key from the OpenAI platform, install the openai package, and initialize a client in its own helper file (like the Firebase setup). The chat completions endpoint takes a model and a list of messages.
// utils/openai.js import OpenAI from "openai"; const openai = new OpenAI({ apiKey: process.env.REACT_APP_OPENAI_KEY, dangerouslyAllowBrowser: true, // see 3.6 - not for production }); export default openai;
utils file, keeping components free of library plumbing.gpt-3.5-turbo unless you truly need GPT-4 — these calls are billed, unlike the free TMDB and Firebase tiers.A raw query ("funny Indian retro movies") gives chatty, unpredictable output. Wrap the user's text in a precise instruction that fixes the role, the count, and the exact format — so the response is easy to parse.
const query = "Act as a movie recommendation system and suggest some movies for " + "the query: " + searchText.current.value + ". Only give me names of 5 movies, comma-separated, " + "like the example result given ahead: " + "Gadar, Sholay, Don, Golmaal, Koi Mil Gaya"; const gptResults = await openai.chat.completions.create({ model: "gpt-3.5-turbo", messages: [{ role: "user", content: query }], }); // the model replies with one comma-separated string const gptMovies = gptResults.choices[0].message.content.split(",");
split(",") into an array. The example is the key — it shows the model the shape.choices[0].message.content. Guard it — if choices is missing, show an error rather than crashing (real error handling left as a TODO in the build).Prompting GPT is like ordering at a deli with a picky order. "Give me a sandwich" gets you something random; "five turkey subs, no mayo, cut in half, like the one in the photo" gets you exactly what you can use. The example in the prompt is the photo — it removes all guesswork about the format.
GPT returns five names, but we need real movie objects (posters, overviews) from TMDB's search endpoint. Map each name to a search call — which gives an array of promises, not results — then await them all together with Promise.all.
const searchMovieTMDB = async (movie) => { const data = await fetch( "https://api.themoviedb.org/3/search/movie?query=" + movie + "&include_adult=false&page=1", API_OPTIONS ); const json = await data.json(); return json.results; }; // map gives an ARRAY OF PROMISES (calls fire in parallel) const promiseArray = gptMovies.map((movie) => searchMovieTMDB(movie)); // Promise.all waits for every call to resolve const tmdbResults = await Promise.all(promiseArray);
gptMovies.map(searchMovieTMDB) produces five promises immediately — the calls don't wait for each other.Promise.all joins them: it takes the array of promises and resolves once all finish, handing back an array of results in order. This is the single most-missed concept in interviews per the build.Promise.all.filter to exact-title matches.OpenAI throws unless you pass dangerouslyAllowBrowser: true — on purpose. Calling it from the browser exposes your key, and an OpenAI key drives real billing. The warning is the SDK protecting you.
Quick to demo, but the key ships in the bundle. Acceptable only for a throwaway/learning project — revoke the key after.
Put the key and the OpenAI call on a backend (e.g. Node). The browser calls your server; the secret never leaves it.
.env: store keys in a .env file (CRA needs the REACT_APP_ prefix) and add it to .gitignore so they're never committed. Note this still bundles them client-side — it hides them from git, not from the browser.dangerouslyAllowBrowser is a learning shortcut. Production work proxies the call through a server.Push the TMDB results into the GPT slice, then render them with the same MovieList component the browse page already uses. No new card code — just feed the existing component different data.
// store the results, then read them back dispatch(addGptMovieResult({ movieNames: gptMovies, movieResults: tmdbResults })); // GptMovieSuggestions.js - reuse MovieList per result row {movieNames.map((name, i) => ( <MovieList key={name} title={name} movies={movieResults[i]} /> ))}
MovieList already renders a titled row of cards, so the suggestions page just passes it the GPT results. Component reuse is the payoff of clean structure.Cache expensive computed values (and avoid needless re-renders/re-fetches) so the same work isn't repeated when state elsewhere changes.
Make it work on phone, tablet, and desktop with responsive utility classes — the browse and search views both adapt.
Set the page <title> and favicon in public/index.html — small touches that make it feel like a real product.
Guard the GPT response (missing choices) and surface a graceful message instead of letting the component throw.
Promise.all waits for all five and gives back the results together — faster than awaiting each in sequence.array.map(asyncFn) returns an array of promises that haven't resolved yet — the calls start but don't block. You then await them (e.g. with Promise.all) to get the actual values.dangerouslyAllowBrowser: true to allow it. The fix is to make the call from a backend so the key never reaches the browser; at minimum keep it in a gitignored .env.MovieList component from the browse page. The GPT results are stored in the slice, then each result row is rendered by passing it to MovieList with a title and the movie array — component reuse instead of duplicate card code.