Movie Explorer App
Build a searchable movie discovery app with Svelte, API integration, filters, pagination, saved favorites, and resilient UI states
Time to implement the project: ~ 24-36 hours
- Svelte Components
- SvelteKit
- API Integration
- Writable Stores
- Search Filters
- Pagination
- Loading States
- Error Handling
In this intermediate Svelte project, you will build a Movie Explorer App that allows users to search for movies, browse results, view movie details, filter content, and save favorite titles. The application should feel like a real discovery tool rather than a simple API demo. Users should be able to enter a search query, receive structured results, move between pages, open a detailed movie view, and keep a personal favorites list that remains available after refresh.
This project is valuable because it combines multiple frontend responsibilities in one practical interface: API communication, reusable component design, shared state, conditional rendering, loading and error states, pagination, and local persistence. You will practice building a Svelte application that responds to user actions, handles unpredictable API responses, and keeps the interface stable even when data is loading, missing, delayed, or unavailable.
Project Goal and Intermediate Learning Value
The main goal of this project is to help you move from basic Svelte syntax into application-level thinking. Instead of building a single isolated component, you will create a small product-like experience with search, results, detail views, saved items, and multiple UI states. This helps you understand how Svelte can be used to build practical interfaces that depend on external data and user choices.
You will learn how to structure a Svelte project so that each part of the interface has a clear responsibility. A strong implementation may include components such as SearchBar, MovieGrid, MovieCard, MovieDetails, PaginationControls, FavoritesList, EmptyState, LoadingState, and ErrorMessage. This structure keeps the app easier to expand and prevents one large component from becoming overloaded with unrelated logic.
The project also teaches an important professional habit: treating API-driven interfaces as uncertain systems. A request may fail, return no results, return incomplete movie data, or take longer than expected. Your job is to design an interface that handles those conditions gracefully. This is the difference between a simple beginner app and an intermediate project that demonstrates real frontend maturity.
Recommended Knowledge Before You Start
This is an intermediate-level Svelte project, so you should already understand components, props, events, reactive declarations, two-way binding, and writable stores. You should also be comfortable with JavaScript array methods, async functions, fetch requests, and basic browser APIs such as localStorage. The project does not require a custom backend, but it should be built with enough structure that it could later be connected to a more advanced API or authentication layer.
The project is best approached as a full frontend application rather than a design-only exercise. The layout matters, but the real learning value comes from managing search state, API responses, favorites, pagination, and conditional screens. You should aim for a clean, predictable experience where every user action has a clear result.
- Good understanding of Svelte components, props, events, stores, and reactive statements
- Basic familiarity with SvelteKit routing or a willingness to organize detail pages and app sections clearly
- Ability to use fetch, async/await, JSON responses, and HTTP error handling
- Comfort with writable stores for favorites, shared UI state, or saved user preferences
- Knowledge of localStorage for persisting favorite movies after page refresh
- Solid CSS skills for responsive grids, cards, buttons, forms, and mobile-friendly layouts
Core Features of the Movie Explorer App
The app should work like a compact movie discovery product. Users should be able to search, scan results quickly, open more detailed information, save favorite movies, and understand what is happening during loading, empty, and error states. The goal is to create a reliable interface where data and user interaction stay synchronized.
| Feature | Implementation Focus |
| Movie search interface | Create a search form with a controlled query value, clear button, submit behavior, and validation for empty searches. The search experience should feel fast and predictable. |
| API-based result loading | Fetch movie data from an external API or mock API source. Handle loading, successful responses, empty results, failed requests, and incomplete data without breaking the layout. |
| Responsive movie grid | Display results in a responsive card grid with poster images, titles, years, ratings, and short metadata. The grid should adapt smoothly from desktop to mobile screens. |
| Movie detail view | Allow users to open a detailed movie view with description, release date, genre, rating, runtime, poster, and external link when available. |
| Favorites store | Use a writable store to save and remove favorite movies. The favorite state should update instantly across cards, detail views, and the favorites section. |
| Local persistence | Store favorite movies in localStorage so users do not lose saved titles after refreshing the page or returning later. |
| Pagination controls | Add next and previous page controls or page numbers so users can browse larger result sets without loading everything at once. |
| Empty and error states | Show helpful messages when no results are found, the API request fails, or the user has not searched yet. Avoid blank sections that make the app feel broken. |
| Optional filters | Add filters such as year, rating, genre, or content type if your chosen API supports them. Filters should update the displayed results clearly and consistently. |
Implementation Guidance for Intermediate Svelte Developers
Start by defining the main data flow before building every visual detail. Decide where the search query lives, where API results are stored, how favorites are shared, and how pagination changes the current result set. This planning step prevents the application from becoming a collection of disconnected components with duplicated state.
Keep the API layer separate from the UI where possible. Even in a small project, it is useful to create a dedicated function for fetching movies and normalizing the response. This makes components easier to read and protects the interface from API-specific field names or inconsistent response structures.
Use Svelte's strengths intentionally. Reactive declarations are excellent for derived values such as filtered results, favorite counts, visible pages, and UI labels. Writable stores are useful for shared data like favorites or user preferences. Avoid putting every piece of state into a store; local component state is still better for temporary UI values that do not need to be shared.
- Normalize API data before rendering it so components receive clean and predictable objects
- Use a writable store for favorites because multiple components need access to the same saved list
- Keep search input state separate from the last submitted query to avoid confusing result updates
- Show loading states immediately when a request starts and clear them reliably after it finishes
- Render fallback content for missing posters, descriptions, ratings, or release dates
- Persist favorites to localStorage only after validating the saved data structure
- Test searches with valid keywords, empty input, no-result terms, slow responses, and API errors
- Make movie cards keyboard-friendly and readable on small screens
Common Mistakes When Building a Movie Explorer App
1. Rendering raw API movie data directly in Svelte components
A Movie Explorer App usually depends on an external movie API, such as TMDB or another film database. A common mistake is using the raw API response directly inside every Svelte component. This feels fast at the beginning, but it makes the app fragile because API fields are often inconsistent, optional, or formatted for machines rather than for your UI.
For example, a movie may have no poster image, no overview, no release date, or a vote average that needs formatting. Some fields may be named with snake_case, while your frontend code uses camelCase. If every component has to remember these details, the app becomes hard to maintain. A better approach is to transform API data once into a clean internal movie model.
This transformation layer also protects the UI from future changes. If you later switch from one API endpoint to another, add server-side loading, or combine search results with popular movies, your components can continue using the same internal structure.
Problematic approach:
<script lang="ts">
export let movie;
const posterUrl = `https://image.tmdb.org/t/p/w500${movie.poster_path}`;
</script>
<article class="movie-card">
<img src={posterUrl} alt={movie.title} />
<h3>{movie.title}</h3>
<p>{movie.release_date}</p>
<p>{movie.vote_average}</p>
</article>
This component depends on raw API field names and assumes every value exists. If poster_path is missing, the image URL becomes broken.
Better internal movie model:
type MovieApiResult = {
id: number;
title: string;
overview: string;
poster_path: string | null;
backdrop_path: string | null;
release_date: string;
vote_average: number;
};
type Movie = {
id: number;
title: string;
overview: string;
posterUrl: string | null;
backdropUrl: string | null;
releaseYear: string;
rating: string;
};
Movie mapper:
const IMAGE_BASE_URL = "https://image.tmdb.org/t/p/w500";
function mapMovie(apiMovie: MovieApiResult): Movie {
return {
id: apiMovie.id,
title: apiMovie.title || "Untitled movie",
overview: apiMovie.overview || "No overview available.",
posterUrl: apiMovie.poster_path
? `${IMAGE_BASE_URL}${apiMovie.poster_path}`
: null,
backdropUrl: apiMovie.backdrop_path
? `${IMAGE_BASE_URL}${apiMovie.backdrop_path}`
: null,
releaseYear: apiMovie.release_date
? apiMovie.release_date.slice(0, 4)
: "Unknown",
rating: apiMovie.vote_average
? apiMovie.vote_average.toFixed(1)
: "N/A"
};
}
Cleaner card component:
<script lang="ts">
export let movie: Movie;
</script>
<article class="movie-card">
{#if movie.posterUrl}
<img src={movie.posterUrl} alt={`${movie.title} poster`} loading="lazy" />
{:else}
<div class="movie-card__poster-placeholder">
No poster
</div>
{/if}
<h3>{movie.title}</h3>
<p>{movie.releaseYear}</p>
<p>Rating: {movie.rating}</p>
</article>
Pay attention to: Do not let raw API data spread across the app. Normalize movie data once, then render clean internal objects everywhere.
2. Fetching all movie data inside components instead of using SvelteKit load functions or API routes
In a SvelteKit Movie Explorer App, data loading can happen in several places: component-level onMount, page load functions, server load
functions, or internal API endpoints. A common mistake is putting all fetching logic directly inside page components with onMount. This works, but it
misses many benefits of SvelteKit.
SvelteKit is especially useful for route-based data. Popular movies can load through a page load function. Movie details can load through a dynamic route such as
/movies/[id]. Sensitive API keys should stay server-side, not inside client-side code. If every fetch runs only in the browser, the app can become slower,
less SEO-friendly, and less secure.
A better implementation separates public UI state from data-loading infrastructure. Client components should render movies, search forms, and filters. Load functions or internal API routes should handle API requests, environment variables, and response normalization.
Problematic approach:
<script lang="ts">
import { onMount } from "svelte";
let movies = [];
let isLoading = true;
onMount(async () => {
const response = await fetch(
`https://api.themoviedb.org/3/movie/popular?api_key=${API_KEY}`
);
const data = await response.json();
movies = data.results;
isLoading = false;
});
</script>
This exposes the idea of an API key in client code and makes the page depend entirely on browser-side fetching.
Better SvelteKit page load:
// src/routes/movies/+page.ts
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, url }) => {
const page = url.searchParams.get("page") || "1";
const query = url.searchParams.get("query") || "";
const endpoint = query
? `/api/movies/search?query=${encodeURIComponent(query)}&page=${page}`
: `/api/movies/popular?page=${page}`;
const response = await fetch(endpoint);
if (!response.ok) {
return {
movies: [],
errorMessage: "Movies could not be loaded."
};
}
return await response.json();
};
Server-side API route:
// src/routes/api/movies/popular/+server.ts
import { json } from "@sveltejs/kit";
import { TMDB_API_KEY } from "$env/static/private";
export async function GET({ url }) {
const page = url.searchParams.get("page") || "1";
const response = await fetch(
`https://api.themoviedb.org/3/movie/popular?api_key=${TMDB_API_KEY}&page=${page}`
);
if (!response.ok) {
return json(
{
movies: [],
errorMessage: "Popular movies could not be loaded."
},
{
status: response.status
}
);
}
const data = await response.json();
return json({
movies: data.results.map(mapMovie),
page: data.page,
totalPages: data.total_pages
});
}
Pay attention to: Use SvelteKit routing and server capabilities intentionally. Keep API keys on the server, use load functions for route data, and let components focus on rendering.
3. Building search without debouncing, cancellation, or URL state
Movie search is one of the core features of this project. A common mistake is sending an API request on every keystroke without debounce or cancellation. If the user types quickly, the app may send many unnecessary requests. Worse, an older request may finish after a newer one and overwrite the latest results with stale data.
Search also works better when the query is reflected in the URL. If the URL contains ?query=batman&page=2, users can refresh the page, share the
search, use browser navigation, and return to the same results. If search state only lives in a component variable, the app feels less reliable.
A strong Movie Explorer App should debounce text input, reset pagination when the query changes, handle empty search terms, and ignore stale responses. In SvelteKit, search can often be driven by query parameters and load functions, which keeps the page state more predictable.
Problematic approach:
let query = "";
let movies = [];
async function handleSearchInput() {
const response = await fetch(`/api/movies/search?query=${query}`);
const data = await response.json();
movies = data.movies;
}
This can fire too many requests and does not protect the UI from stale responses.
Better debounced search:
import { goto } from "$app/navigation";
let query = "";
let debounceTimer: number | undefined;
function handleSearchInput() {
window.clearTimeout(debounceTimer);
debounceTimer = window.setTimeout(() => {
const trimmedQuery = query.trim();
const params = new URLSearchParams();
if (trimmedQuery) {
params.set("query", trimmedQuery);
}
params.set("page", "1");
goto(`/movies?${params.toString()}`, {
keepFocus: true,
noScroll: false
});
}, 350);
}
Search form markup:
<form class="movie-search" role="search" on:submit|preventDefault>
<label for="movie-search-input">Search movies</label>
<input
id="movie-search-input"
type="search"
bind:value={query}
on:input={handleSearchInput}
placeholder="Search by movie title..."
autocomplete="off"
/>
</form>
Handling empty queries:
$: isSearching = query.trim().length > 0;
$: emptyStateTitle = isSearching
? "No movies match your search"
: "No movies available right now";
Pay attention to: Search should be debounced, shareable through the URL, and safe from stale updates. Reset page number when the query changes.
4. Treating pagination as a visual button instead of part of the data model
Pagination is not just a pair of “Next” and “Previous” buttons. It is part of the movie data model. The app needs to know the current page, total pages, whether there is a next page, whether there is a previous page, and whether pagination belongs to popular movies or search results. A common mistake is incrementing a local page variable without synchronizing it with the URL or API response.
This causes confusing behavior. The user searches for one movie, goes to page three, changes the search query, and the app still tries to load page three for the new query. Or the app allows “Next” even when there are no more pages. Good pagination should be driven by API metadata and should be reflected in URL parameters.
Problematic approach:
let page = 1;
function nextPage() {
page += 1;
loadMovies();
}
function previousPage() {
page -= 1;
loadMovies();
}
This can produce invalid pages such as 0 and does not know whether the current dataset has more results.
Better pagination model:
type MoviePagination = {
page: number;
totalPages: number;
totalResults: number;
};
function createPaginationState(apiResponse: {
page: number;
total_pages: number;
total_results: number;
}): MoviePagination {
return {
page: apiResponse.page,
totalPages: apiResponse.total_pages,
totalResults: apiResponse.total_results
};
}
function canGoNext(pagination: MoviePagination): boolean {
return pagination.page < pagination.totalPages;
}
function canGoPrevious(pagination: MoviePagination): boolean {
return pagination.page > 1;
}
URL-based page navigation:
import { goto } from "$app/navigation";
import { page as pageStore } from "$app/stores";
function goToMoviePage(nextPage: number) {
const params = new URLSearchParams($pageStore.url.searchParams);
params.set("page", String(nextPage));
goto(`/movies?${params.toString()}`);
}
Pagination controls:
<nav class="pagination" aria-label="Movie results pages">
<button
type="button"
disabled={!canGoPrevious(pagination)}
on:click={() => goToMoviePage(pagination.page - 1)}
>
Previous
</button>
<span>
Page {pagination.page} of {pagination.totalPages}
</span>
<button
type="button"
disabled={!canGoNext(pagination)}
on:click={() => goToMoviePage(pagination.page + 1)}
>
Next
</button>
</nav>
Pay attention to: Pagination should come from API metadata, stay synchronized with URL search parameters, and prevent invalid navigation.
5. Showing one generic state for loading, empty results, and errors
Movie apps depend on external data, so loading and error states are not optional. A common mistake is using one generic message for everything: “No movies found.” But this could mean the app is still loading, the API request failed, the search returned no results, the API key is missing, or the user is offline. These are different situations and the UI should communicate them differently.
A polished Movie Explorer App should include at least four clear states: loading, success, empty, and error. Loading should show skeleton cards or a spinner. Empty search should explain that no results matched the query. API errors should offer a retry path. Missing posters should have image-level fallbacks, not break the whole card.
In SvelteKit, some of this can be handled through load return values, error boundaries, or route-level data. The important part is that users are never left looking at a blank grid without explanation.
Problematic approach:
{#if movies.length}
<MovieGrid {movies} />
{:else}
<p>No movies found.</p>
{/if}
This shows “No movies found” even while loading or after an API error.
Better state model:
type MovieListState =
| { status: "loading" }
| { status: "success"; movies: Movie[]; pagination: MoviePagination }
| { status: "empty"; message: string }
| { status: "error"; message: string };
function createMovieListState(data: {
movies: Movie[];
pagination?: MoviePagination;
errorMessage?: string;
}): MovieListState {
if (data.errorMessage) {
return {
status: "error",
message: data.errorMessage
};
}
if (!data.movies.length) {
return {
status: "empty",
message: "No movies match your current filters."
};
}
return {
status: "success",
movies: data.movies,
pagination: data.pagination!
};
}
State-aware rendering:
{#if movieListState.status === "loading"}
<MovieGridSkeleton count={12} />
{:else if movieListState.status === "error"}
<ErrorState
title="Movies could not be loaded"
description={movieListState.message}
actionLabel="Try again"
on:retry={reloadMovies}
/>
{:else if movieListState.status === "empty"}
<EmptyState
title="No movies found"
description={movieListState.message}
/>
{:else}
<MovieGrid movies={movieListState.movies} />
{/if}
Poster fallback:
{#if movie.posterUrl}
<img src={movie.posterUrl} alt={`${movie.title} poster`} loading="lazy" />
{:else}
<div class="movie-card__poster-placeholder">
Poster unavailable
</div>
{/if}
Pay attention to: Loading, empty, error, and missing-image states are different. Treat them as first-class UI states instead of using one generic message.
6. Mixing global store state with route state and filter state
Svelte stores are useful, but they should not become a dumping ground for every piece of state. A Movie Explorer App may have global favorites, theme preference, recently viewed movies, search query, selected filters, current page, selected movie details, and API loading state. A common mistake is putting all of this into one global store. That makes the app harder to debug because route-specific state and long-lived user preferences are mixed together.
A better approach is to decide what belongs where. Search query, filters, and pagination often belong in the URL because they describe the current route. Favorites and theme preference can live in stores because they should persist across pages. Movie details can be loaded by the dynamic route. Loading and error states can be local to the page load result.
This separation makes the app feel more professional. Users can share search results, return to the same page, favorite movies across sessions, and open detail pages without state conflicts.
Problematic store:
export const movieStore = writable({
query: "",
page: 1,
selectedGenre: "all",
movies: [],
selectedMovie: null,
favorites: [],
isLoading: false,
error: null,
theme: "dark"
});
This store mixes route state, fetched data, favorites, theme, and UI request state in one object.
Better separation:
// URL state:
// /movies?query=alien&genre=horror&page=2
// Persistent user preference:
export const favoriteMovieIds = writable<number[]>([]);
export const themePreference = writable<"light" | "dark">("dark");
Favorites store with localStorage:
import { writable } from "svelte/store";
const FAVORITES_KEY = "movie-explorer-favorites";
function createFavoritesStore() {
const initialValue = JSON.parse(
localStorage.getItem(FAVORITES_KEY) || "[]"
);
const { subscribe, update } = writable<number[]>(initialValue);
subscribe((ids) => {
localStorage.setItem(FAVORITES_KEY, JSON.stringify(ids));
});
return {
subscribe,
toggle: (movieId: number) => {
update((ids) => {
return ids.includes(movieId)
? ids.filter((id) => id !== movieId)
: [...ids, movieId];
});
}
};
}
export const favoritesStore = createFavoritesStore();
Derived favorite state in a component:
<script lang="ts">
import { favoritesStore } from "$lib/stores/favorites";
export let movie: Movie;
$: isFavorite = $favoritesStore.includes(movie.id);
</script>
<button
type="button"
aria-pressed={isFavorite}
on:click={() => favoritesStore.toggle(movie.id)}
>
{isFavorite ? "Remove from favorites" : "Add to favorites"}
</button>
Pay attention to: Use stores for long-lived shared state, not for everything. Keep search, filters, and pagination in the URL; keep favorites and theme in stores.
After completing this project, you will have a strong intermediate Svelte portfolio application that demonstrates API integration, reusable component architecture, stores, reactive data handling, pagination, persistence, and resilient UI states. This project is useful because it reflects the kind of work frontend developers do in real products: taking user input, requesting external data, handling uncertainty, organizing shared state, and presenting results in a clean interface that works across devices.
Reference Implementations Worth Studying
SvelteKit architecture and route features reference:
ScriptRaccoon - SvelteKit Movies
This is the strongest architecture reference for the Movie Explorer App because it demonstrates a movie search website built with SvelteKit. The project highlights core SvelteKit features such as Svelte components, pages, dynamic routes, layouts, page server loads, API endpoints, TypeScript support, and Sass support.
Pay particular attention to:
- How SvelteKit routes can separate movie list pages from movie detail pages.
- How dynamic routes can support individual movie pages by ID or slug.
- How page server loads and API endpoints can keep API logic outside client components.
- How TypeScript and Sass can make the project feel more maintainable and production-ready.
- How a movie search site can demonstrate more than simple component rendering by using framework-level routing features.
Use this repository as the main SvelteKit structure reference. It is especially useful if your project should show routing, server loading, internal endpoints, and a clean separation between pages and UI components.
TMDB popular movies and details reference:
avs-7955 - Movie App SvelteKit
This implementation is useful because it focuses directly on a SvelteKit movie website using TMDB data. The app shows popular movies on the homepage, supports movie detail pages, and includes search functionality for finding related movies. That makes it a practical reference for the core Movie Explorer App experience.
When studying the code, focus on:
- How popular movies are fetched and displayed as the default homepage experience.
- How movie detail pages present additional information after the user selects a movie.
- How search functionality changes the data flow compared with a static popular-movies list.
- How TMDB-specific fields such as poster path, overview, release date, and rating are used in the UI.
- What improvements would make the app stronger: loading skeletons, typed response models, pagination, and more specific error states.
Use this repository as the TMDB feature reference. It is especially helpful for understanding the minimum feature set of a movie explorer: popular list, search results, and individual movie details.
Older Svelte movie search and i18n reference:
cmelgarejo - Svelte Movie Search
This repository is valuable as a historical Svelte movie-search reference. It is a Svelte implementation of movie search using the TMDB API and includes Sapper and svelte-i18n. Although Sapper is older than SvelteKit, the project is still useful for studying movie search behavior, API-driven UI, and internationalization ideas.
While reviewing this project, examine:
- How a Svelte-based app can organize movie search around TMDB API data.
- How Sapper-style routing compares with newer SvelteKit routing patterns.
- How i18n can become relevant when a movie explorer targets users in multiple languages.
- How deployed demos and API-backed search flows can make a small frontend project feel complete.
- What you would modernize in a SvelteKit version: load functions, server endpoints, typed data, and current routing conventions.
Use this implementation as the alternative search and internationalization reference. It should not replace a modern SvelteKit architecture, but it can inspire the search flow and show how movie API projects looked in earlier Svelte ecosystems.