Netflix GPT — Build Log
Shipping the thing — scaffolding the app, auth and protected routes, Redux Toolkit state, and a GPT-powered search backed by a movie API.
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.
A real app starts with decisions, not code: scaffold the project, map the routes, sketch the components. Then the first feature — one form that toggles between sign-in and sign-up via state, reads its inputs with refs, and validates before doing anything.#
What we're building & why it's worth it#
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.
Login + protected routes
A backend-as-a-service (Firebase) handles sign-up/sign-in, so the browse page is reachable only when logged in.
Sign-in / sign-up + validation
Hand-rolled form handling and validation — the bread-and-butter of almost every app.
Prompt → movie picks
A search box that sends a natural-language query to a GPT API and renders the suggested titles.
- Concepts > projects: the real takeaway isn't "Netflix" — it's the reusable building blocks. The same auth + form + external-API pattern builds an e-commerce site, a food app, or anything else.
- UI matters here: this build leans into CSS (via Tailwind) to make it look real, not just function. Polish is part of the deliverable.
Scaffolding with Create React App#
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
From scratch (Parcel)
You install and configure the bundler, testing library, Babel, etc. Maximum control, maximum setup time. Good to understand once.
Scaffold (CRA)
One command sets up Webpack (the bundler), Jest + React Testing Library, and npm scripts (start, build, test). You start building immediately.
- CRA bundles with Webpack: same role Parcel played earlier — it compiles and serves your code. You just don't configure it yourself.
- Batteries included: testing is pre-wired (there's already a sample test), and hot reload works on save without any setup.
- It's effectively a framework on top of React: React itself is a library, but CRA layers structure, build, and conventions around it — that's why your app boots instantly.
- Alternatives exist & are rising: Vite is a faster, increasingly popular scaffold. CRA is fine to learn on; reach for Vite (or a framework like Next.js) on newer projects.
Clean the slate first
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.
Adding Tailwind#
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;
- The
contentpath 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. - Why utility-first pays off here: building a custom layout (overlapping header, gradient, centered form) is fast with utilities and painful in hand-written CSS — that's the whole reason it's used in this build.
Planning the app: routes & components#
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} />;
- Folder convention:
/componentsfor UI pieces (Login, Header, Browse),/utilsfor shared logic (validation, constants, config). Keeps the tree predictable as it grows. - Keep the root clean: do routing in
App.jsand nothing else — every real screen is its own component mounted by the router. - Routing is from react-router-dom:
createBrowserRouterdefines the path→element map;RouterProvidermakes it live. (Same API as the foundations notes.)
"You cannot render a Router inside another Router"
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.
One form, two modes (the toggle pattern)#
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>
- Toggle with
!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. - Conditional fields via
&&: sign-up adds a name field; render it only when!isSignIn. No second form, no duplication. - Reusable far beyond auth: the same state-driven toggle swaps cards, tabs, or any either/or UI. That's the transferable lesson.
Reading inputs: useRef vs controlled state#
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; };
useRef (uncontrolled)
The input owns its value; you read ref.current.value only when needed. No re-render per keypress. Great for read-on-submit forms.
useState (controlled)
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).
- What a ref actually is:
useRef(null)returns a mutable box; attach it withref={email}and React pointsemail.currentat the real DOM node..current.valueis the live input text. - Refs don't trigger renders: changing a ref is invisible to React's render cycle — that's exactly why they're cheap for "just read it when I click submit."
- Rule of thumb: need the value continuously (live feedback)? Use state. Need it only at a moment (submit)? A ref is simpler.
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.
Form validation, extracted to a utility#
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 };
- Why
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. - Validate with regex: a regular expression is a pattern;
regex.test(value)returns true/false. Email checks forname@domain.tld; password here wants 8+ chars with upper, lower, and a digit. - Return-message contract: the helper returns a string on failure or
nullon success. The component just stores it in state — truthy shows the error,nullclears it. - State for the message, ref for the inputs: the error must update the UI live, so it's
useState; the input values are read once on click, so they're refs. Clean division of the two hooks. - For big forms, reach for a library: hand-rolled validation is fine for two fields; with dozens, a form library (e.g. Formik) handles values, errors, and touched-state without the boilerplate.
Gotchas & nice-to-knows#
Form reloads the page on click
A <button> in a form triggers submit → full reload. Call e.preventDefault() in the handler to stop it.
ref value is undefined
You read email.value instead of email.current.value. The ref is a box; the node lives on .current.
Tailwind classes do nothing
The content glob in the config doesn't match your files, so Tailwind purges everything. Point it at ./src/**/*.
Overlapping layout won't stack right
Use absolute for the background, a higher z-index for the header, so the form and nav sit above the hero image.
rafce snippet
Typing rafce in a JS/JSX file scaffolds a full arrow-function component with export — saves writing the boilerplate.
Validation as a pure function
Keeping checkValidData pure (inputs in, message out, no side effects) makes it trivial to reuse and unit-test.
Interview Q&A#
Q.What is Create React App and what does it set up for you?
Q.What's the difference between a controlled and an uncontrolled component?
Q.What is useRef and when would you use it over useState?
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.Q.How do you build sign-in and sign-up without duplicating the form?
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.Q.Why does a form reload the page on submit, and how do you stop it?
<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.Q.How would you validate a form, and where should that logic live?
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.Q.What's a protected route and how does it fit the routing plan?
/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.Q.Why split code into /components and /utils?
/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.Routing shouldn't be scattered across components. Put one auth listener in a place that's always mounted (the header), let it redirect on every auth change, then feed the browse page from the store via a custom hook — so each component does one job.#
The problem: routes that don't guard themselves#
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.
Browse is reachable when logged out
Typing the /browse URL with no user shows private content. It must redirect to login.
Login is reachable when logged in
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.
Centralizing the listener in the header#
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 }, []);
- One listener, every route: because the header is always rendered, this runs on every page. Any auth change — login, signup, logout — triggers the right redirect automatically.
- Delete the scattered navigates: once this exists, you remove the manual
navigate("/browse")from the login handler and thenavigate("/")from sign-out. The listener owns all routing now — single source of truth. - Why not the body/parent: a component that renders the
RouterProvidersits outside the routes, so itsuseNavigatehas 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.
Cleaning up the listener (useEffect return)#
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.
- The cleanup function: whatever you
returnfrom auseEffectruns when the component unmounts (and before the effect re-runs). Returning() => unsubscribe()detaches the Firebase listener cleanly. - Why it matters: without it, navigating in and out of the header repeatedly registers duplicate listeners — wasted work and subtle double-fire bugs. This is the same lifecycle idea as
componentWillUnmountin class components.
Hygiene: a constants file#
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>", }, };
- One place to change: if the logo or default avatar updates, you edit a single line instead of hunting copies across components. That's the whole payoff.
- Watch the JSX braces: once a value is imported as a JS constant, use it directly (
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).
Fetching real data from TMDB#
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 };
- Auth on every call: TMDB rejects requests without the token. The
API_OPTIONSobject (with theAuthorization: Bearer ...header) goes into every fetch — that's why it lives in constants. - Read the docs, don't guess: the endpoint, the GET method, and the sample request all come from TMDB's API reference. Being fluent at reading API docs is the transferable skill here.
- The data shape: the response's
resultsarray holds the movies; each item hasoriginal_title,overview,vote_average, etc. That's what the UI will read.
Why the API fires twice in dev
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.)
A movies slice in the store#
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; }, }, });
- Separate slice = clean state: the store stays organized —
state.userfor the person,state.moviesfor content. Each slice plugs into the store's reducer object independently. - Dispatch the results: after fetching,
dispatch(addNowPlayingMovies(json.results))writes the 20 movies into the store; components then read them with a selector.
Extracting a custom hook#
"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 live in their own folder: a
/hooksdirectory keeps custom hooks separate from components and utils — predictable structure as the app grows. - The
useprefix 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. - Reuse by parameterizing: the same shape makes
usePopularMovies(and top-rated, upcoming) trivial — swap the endpoint and the dispatch action. Each movie row gets its own hook.
Structuring the browse UI#
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 };
- Optional chaining guards the read:
store.movies?.nowPlayingMovieswon't crash ifmoviesis briefly undefined during the first render. - Early return for the empty state:
if (!movies) return nullskips rendering until the store is populated — otherwisemovies[0]throws onnull. A clean, common pattern. - Selectors connect UI to store: each container subscribes only to the slice it needs, so it re-renders when (and only when) that data changes.
Gotchas & nice-to-knows#
useNavigate outside a Router
"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.
Listener piles up
Re-mounting re-subscribes onAuthStateChanged. Return its unsubscribe from the effect to tear it down.
API called twice
StrictMode double-invokes effects in dev only. Don't "fix" it by deleting StrictMode in production code.
movies[0] crashes on load
The store is null before the fetch resolves. Guard with optional chaining + an early return.
Reading API docs is the skill
Endpoint, method, and auth header all come from TMDB's reference — learn to read docs rather than copy snippets blindly.
One hook per data source
Cloning the now-playing hook for each category keeps fetching logic out of components and trivially repeatable.
Interview Q&A#
Q.How do you implement protected routes in a React app?
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.Q.Why does useNavigate throw "may only be used in the context of a Router"?
Q.What is a useEffect cleanup function and when does it run?
unsubscribe so the auth listener doesn't leak or duplicate.Q.Why do effects and renders run twice in development?
Q.What is a custom hook and why extract one here?
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.Q.Why keep hard-coded values in a constants file?
Q.How do you avoid crashing when store data isn't loaded yet?
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.Q.Why give movie data its own Redux slice?
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.The headline feature is a pipeline: user prompt → OpenAI returns movie names → search each on TMDB → render. The React lessons live in the glue — a well-engineered prompt, parallel API calls with Promise.all, and keeping secrets out of the client.#
The feature & how to toggle into it#
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 /> </> );
- Why a new slice: user data, movie data, and GPT state are separate concerns, so each gets its own slice. Logical separation keeps the store readable as features pile up.
- Fragment for the either/or: JSX needs one parent, so the two-component branch is wrapped in a
<>...</>fragment — no extra DOM node. - The header button doubles as the back link: its label flips too (GPT Search ↔ Home) by reading the same flag, so one button drives the whole toggle.
Bonus: making the app multilingual#
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>
- It's the constants rule, scaled up: the same "no hard-coded strings" habit becomes localization — one config object per language, indexed by a key.
- Supported-languages list is also a constant: the dropdown options come from an array of
{ identifier, name }so adding a language is one entry, not edits in two places. - Why it's worth it: a multilingual toggle is a strong portfolio/interview signal and helps SEO — cheap to add once strings are externalized.
Wiring up OpenAI#
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;
- Helper file pattern: third-party setup (client init + auth) lives in its own
utilsfile, keeping components free of library plumbing. - Pick the cheap model: use
gpt-3.5-turbounless you truly need GPT-4 — these calls are billed, unlike the free TMDB and Firebase tiers. - Billing reality: the key only works once a billing method is set; a free trial credit may apply first. Treat usage as metered.
Prompt engineering: ask for a parseable answer#
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(",");
- Constrain the output: stating the count and "comma-separated, like this example" turns free-form prose into a string you can
split(",")into an array. The example is the key — it shows the model the shape. - Read the response carefully: the text is at
choices[0].message.content. Guard it — ifchoicesis missing, show an error rather than crashing (real error handling left as a TODO in the build). - Garbage in, garbage out: the model is only as good as the prompt. Specific role + format + example beats a bare query every time.
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.
Searching TMDB for each title, in parallel#
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);
- map + async = array of promises: an async function always returns a promise, so
gptMovies.map(searchMovieTMDB)produces five promises immediately — the calls don't wait for each other. Promise.alljoins 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.- Parallel, not sequential: firing all five at once and awaiting together is far faster than awaiting each in a loop. That's the reason to reach for
Promise.all. - Each search may return several matches: a title can exist in multiple languages, so you get an array-of-arrays — show all, or
filterto exact-title matches.
Keeping the secret out of the client#
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.
Client-side (this build)
Quick to demo, but the key ships in the bundle. Acceptable only for a throwaway/learning project — revoke the key after.
Server-side (correct)
Put the key and the OpenAI call on a backend (e.g. Node). The browser calls your server; the secret never leaves it.
- Two tiers of secrecy: the TMDB key is free, so a leak just hits a rate limit. The OpenAI key spends money — treat it as a true secret.
- At minimum, use
.env: store keys in a.envfile (CRA needs theREACT_APP_prefix) and add it to.gitignoreso they're never committed. Note this still bundles them client-side — it hides them from git, not from the browser. - The real fix is a backend:
dangerouslyAllowBrowseris a learning shortcut. Production work proxies the call through a server.
Rendering results by reusing components#
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]} /> ))}
- Reuse beats rewrite: the browse page's
MovieListalready renders a titled row of cards, so the suggestions page just passes it the GPT results. Component reuse is the payoff of clean structure. - Store the names too: keeping both the GPT names and the TMDB results in the slice lets each suggestion row show the queried title alongside its matches.
- Show feedback while waiting: the GPT + 5 TMDB calls take a moment — a shimmer/loader keeps the UI honest during the async gap.
Memoization & final polish#
Memoization
Cache expensive computed values (and avoid needless re-renders/re-fetches) so the same work isn't repeated when state elsewhere changes.
Responsive layout
Make it work on phone, tablet, and desktop with responsive utility classes — the browse and search views both adapt.
Title & favicon
Set the page <title> and favicon in public/index.html — small touches that make it feel like a real product.
Error handling
Guard the GPT response (missing choices) and surface a graceful message instead of letting the component throw.
- Memoize the expensive, not everything: reach for it when a value is costly to compute or a child re-renders too often — not as a blanket habit.
- Responsiveness is a feature: a layout that breaks on mobile undercuts the whole project; test the small screens explicitly.
Interview Q&A#
Q.How would you integrate an LLM API like OpenAI into a React app?
Q.Why is prompt engineering important when calling GPT programmatically?
Q.What does Promise.all do and why use it here?
Promise.all waits for all five and gives back the results together — faster than awaiting each in sequence.Q.Why does mapping an async function give promises instead of values?
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.Q.Why is calling OpenAI from the browser a problem, and how do you fix it?
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.Q.How do you make a React app multilingual?
Q.How did you display the GPT results without writing new UI?
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.