Home / Articles

Building a Resilient Movie Detail Page with Next.js App Router

Fetch and cache OMDB data in the Next.js App Router with async Server Components, awaited params and real 404 handling for a movie page that doesn't break.

Publisher: REACTAPP.TOP7 min read1556 words

This article is published in English.

Sources: A Movie Lookup Page in Next.js That Doesn’t Fall Over

A lot of tutorials on building a movie lookup page with the OMDB API are still circulating with code from 2019: getServerSideProps, an axios import, a pages/ directory, and a fetch helper that just dumps every error to console.error. None of that runs on a modern setup anymore. Since Next.js 16, data fetching lives in async Server Components, and params is now a Promise you must await before using. Drop that old code into a freshly generated create-next-app project and the type checker will refuse to cooperate.

What follows is the version worth actually shipping: a dynamic movie detail page powered by the OMDB API, built with the App Router, no axios dependency, and a 404 page that genuinely behaves like a 404.

The data layer

OMDB is a straightforward REST API. Send it a title, get back JSON. There's no client library worth pulling in, and axios adds nothing here — fetch is now built into both the browser and Node, and Next.js layers its own caching on top of fetch in a way axios simply can't participate in.

A detail that catches people out: OMDB responds with HTTP 200 even when it fails to find a movie. The real failure indicator lives in the payload, as "Response": "False". If your code only checks res.ok, a misspelled title will look like a successful call, and you'll end up rendering undefined throughout the page.

const BASE_URL = "https://www.omdbapi.com/";
export type Movie = {
  Title: string;
  Year: string;
  Plot: string;
  Poster: string;
  imdbRating: string;
  Response: "True" | "False";
  Error?: string;
};export async function fetchMovieByTitle(title: string): Promise<Movie | null> {
  const apiKey = process.env.OMDB_API_KEY;
  if (!apiKey) throw new Error("OMDB_API_KEY is not set");  const url = new URL(BASE_URL);
  url.searchParams.set("apikey", apiKey);
  url.searchParams.set("t", title);
  url.searchParams.set("plot", "full");  const res = await fetch(url, { next: { revalidate: 60 * 60 * 24 } });
  if (!res.ok) return null;  const data = (await res.json()) as Movie;
  return data.Response === "True" ? data : null;
}

The next: { revalidate: ... } option on fetch is what actually gives this setup real scalability. A movie's plot summary and poster are effectively static, so caching the response for a full day means the second person who visits /movie/inception never triggers another OMDB call at all. That's the entire caching strategy — no Redis instance, no revalidation cron job needed. Want fresher data? Lower the number. Want it to never expire? Switch to cache: "force-cache". Ten cold lookups against OMDB directly averaged 57.8 ms per request (p50 of 55.5 ms, with a worst case of 112.8 ms), so the point of caching isn't that their API is sluggish — it's avoiding paying even that modest cost on every single request.

Constructing the request URL with URLSearchParams instead of manual string interpolation matters more than it seems. Movie titles routinely contain spaces, ampersands, and colons. A title like t: "The Good, the Bad and the Ugly" will break if you concatenate it by hand, but searchParams.set handles the encoding automatically.

The page

This is where older tutorials aren't just outdated — they're actually broken. Under the App Router, a page component is an async function, and params shows up as a Promise, which you have to await. Skip that step and TypeScript won't just warn you; next build fails outright.

import Image from "next/image";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { fetchMovieByTitle } from "@/lib/omdb";
type PageProps = { params: Promise<{ title: string }> };export async function generateMetadata({
  params,
}: PageProps): Promise<Metadata> {
  const { title } = await params;
  const movie = await fetchMovieByTitle(decodeURIComponent(title));
  if (!movie) return { title: "Movie not found" };
  return { title: `${movie.Title} (${movie.Year})`, description: movie.Plot };
}export default async function MoviePage({ params }: PageProps) {
  const { title } = await params;
  const movie = await fetchMovieByTitle(decodeURIComponent(title));
  if (!movie) notFound();  return (
    <main>
      <h1>
        {movie.Title} <span>({movie.Year})</span>
      </h1>
      <p>IMDb {movie.imdbRating}</p>
      <p>{movie.Plot}</p>
      {movie.Poster !== "N/A" && (
        <Image
          src={movie.Poster}
          alt={`Poster for ${movie.Title}`}
          width={300}
          height={445}
          priority
        />
      )}
    </main>
  );
}

The call to notFound() is doing meaningful work here. Rather than rendering a JSX element like <div>Movie not found</div> while still returning a 200 status code — which is exactly what the older versions do, effectively telling Google "this page is real, go ahead and index it" — notFound() throws an exception, renders your not-found.tsx file, and returns an actual 404 status. Given that most guessed titles in a URL-driven lookup like this won't resolve to anything, that distinction is what keeps your site from having a pile of junk pages in the search index.

The generateMetadata function calls the exact same data-fetching helper as the page itself. You might expect that to double the number of API calls, but it doesn't: Next.js automatically deduplicates identical fetch requests that occur within a single render pass, so the metadata generator and the page component end up sharing one network call. This is a solid argument for keeping the fetching logic inside Server Components rather than moving it to a client-side useEffect — a client-rendered version has no way to deduplicate across that metadata boundary.

There are two OMDB quirks specifically tied to poster handling that are easy to miss. First, a missing poster comes back as the literal string "N/A" rather than null, so you need a check like movie.Poster !== "N/A" before passing the value into next/image — feed it "N/A" directly as a src and you'll get a broken image plus a console warning. Second, next/image refuses to load images from a remote domain that hasn't been explicitly allow-listed, so OMDB's image host needs its own entry in next.config:

const nextConfig = {
  images: { remotePatterns: [{ protocol: "https", hostname: "m.media-amazon.com" }] },
};
export default nextConfig;

What this doesn't do yet

There's no search box in this implementation — it resolves one exact title from the URL, and that's intentional, because OMDB's t= parameter performs an exact match, while its fuzzy s= search lives on a separate, less predictable endpoint. There's also no persistence layer; every unique lookup still reaches OMDB, just behind the cache. If your project needs features like a favorites list or watch history, that calls for an actual database, and that's a separate concern outside the scope of this page.

The first thing worth addressing before going to production is OMDB's free tier limit of 1,000 requests per day. Daily revalidation stretches that quota a long way, but once traffic exceeds hobby-project levels, you'll want your own storage layer sitting in front of OMDB — a read-through cache that only populates on a miss. Everything described above is essentially the front half of that architecture.

The takeaway is that "scalable" in this context isn't some framework you bolt on afterward. It comes down to setting one revalidate value correctly, returning a proper 404, and making sure you never call the API twice when a single call will do.