As a developer primarily focused on backend, I've always felt that my frontend skills could use some polishing. To test this, I decided to challenge myself by building a Netflix clone using Vue.js 3 and Vite. In this article, I'll break down the project structure, key components, and share my learning experience.
Project Overview
The goal was to create a responsive web application that mimics the core features of Netflix's user interface. Here's what I initially set out to build:
- A homepage with multiple rows of movies, categorized by genre
- Smooth horizontal scrolling for movie rows
- Lazy loading of images for better performance
- A search functionality to find movies
More to be added in the future.
Tech Stack
For this project, I chose the following tools:
- Vue.js 3: For its reactivity system and component-based architecture
- Vite: As a fast build tool and development server
- Vue Router: For handling navigation
- Pinia: For state management
- Axios: For making API requests to TMDB
- @vueuse/motion: For adding smooth animations
Project Structure
Here's an overview of the project structure:
netflix-clone/
├── src/
│ ├── components/
│ │ ├── MovieCard.vue
│ │ ├── MovieList.vue
│ │ ├── MovieRow.vue
│ │ └── NavBar.vue
│ ├── views/
│ │ ├── HomeView.vue
│ │ ├── MovieDetailView.vue
│ │ └── SearchView.vue
│ ├── router/
│ │ └── index.js
│ ├── services/
│ │ └── tmdb.js
│ ├── stores/
│ │ └── movies.js
│ ├── App.vue
│ └── main.js
├── .env.example
├── vite.config.js
└── package.json
Key Components Breakdown
MovieCard.vue
This component represents an individual movie. It displays the movie poster and, on hover, shows additional information like the title, rating, and release year.
<template>
<div class="movie-card" @mouseenter="startHoverTimer" @mouseleave="resetHover">
<img :src="posterUrl" :alt="movie.title" @load="imageLoaded = true" :class="{ 'loaded': imageLoaded }">
<div v-if="isHovered" class="movie-info">
<h3>{{ movie.title }}</h3>
<p>Rating: {{ movie.vote_average }}/10</p>
<p>{{ releaseYear }}</p>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
const props = defineProps(['movie']);
const imageLoaded = ref(false);
const isHovered = ref(false);
const posterUrl = computed(() => `https://image.tmdb.org/t/p/w500${props.movie.poster_path}`);
const releaseYear = computed(() => new Date(props.movie.release_date).getFullYear());
// ... hover logic
</script>
Key learnings:
- Using
computed
properties for derived data - Implementing hover effects with CSS transitions
- Lazy loading images for better performance
MovieRow.vue
This component creates a horizontally scrollable row of movies, typically grouped by genre.
<template>
<div class="movie-row">
<h2>{{ title }}</h2>
<div class="movie-list" ref="movieList">
<MovieCard v-for="movie in movies" :key="movie.id" :movie="movie" />
</div>
<button @click="scroll('left')" class="scroll-btn left"><</button>
<button @click="scroll('right')" class="scroll-btn right">></button>
</div>
</template>
<script setup>
import { ref } from 'vue';
import MovieCard from './MovieCard.vue';
const props = defineProps(['title', 'movies']);
const movieList = ref(null);
const scroll = (direction) => {
const scrollAmount = direction === 'left' ? -300 : 300;
movieList.value.scrollBy({ left: scrollAmount, behavior: 'smooth' });
};
</script>
### tmdb.js (API Service)
This service handles all API calls to The Movie Database (TMDB) using Axios.
import axios from 'axios';
const API_KEY = import.meta.env.VITE_TMDB_API_KEY;
const BASE_URL = 'https://api.themoviedb.org/3';
const tmdbApi = axios.create({
baseURL: BASE_URL,
params: { api_key: API_KEY },
});
export const getTrending = () => tmdbApi.get('/trending/all/week');
export const getMoviesByGenre = (genreId) => tmdbApi.get('/discover/movie', { params: { with_genres: genreId } });
export const searchMovies = (query) => tmdbApi.get('/search/movie', { params: { query } });
NavBar.vue
The NavBar component provides navigation for the application and includes a search input for finding movies.
<template>
<nav class="navbar">
<router-link to="/" class="navbar-brand">NetflixClone</router-link>
<div class="navbar-links">
<router-link to="/">Home</router-link>
<div class="search-container">
<input v-model="searchQuery" @input="debounceSearch" placeholder="Search movies...">
</div>
</div>
</nav>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import debounce from 'lodash/debounce';
const router = useRouter();
const searchQuery = ref('');
const debounceSearch = debounce(() => {
if (searchQuery.value) {
router.push({ name: 'search', query: { q: searchQuery.value } });
}
}, 300);
</script>
HomeView.vue
The HomeView component serves as the main page of the application, displaying multiple MovieRow components with different genres.
<template>
<div class="home-view">
<MovieRow title="Trending" :movies="trendingMovies" />
<MovieRow v-for="genre in genres" :key="genre.id" :title="genre.name" :movies="moviesByGenre[genre.id]" />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import MovieRow from '@/components/MovieRow.vue';
import { getTrending, getGenres, getMoviesByGenre } from '@/services/tmdb';
const trendingMovies = ref([]);
const genres = ref([]);
const moviesByGenre = ref({});
onMounted(async () => {
const [trendingResponse, genresResponse] = await Promise.all([
getTrending(),
getGenres()
]);
trendingMovies.value = trendingResponse.data.results;
genres.value = genresResponse.data.genres.slice(0, 5); // Limit to 5 genres for this example
for (const genre of genres.value) {
const response = await getMoviesByGenre(genre.id);
moviesByGenre.value[genre.id] = response.data.results;
}
});
</script>
SearchView.vue
The SearchView component displays search results based on the user's query.
<template>
<div class="search-view">
<h2>Search Results for "{{ searchQuery }}"</h2>
<div class="search-results">
<MovieCard v-for="movie in searchResults" :key="movie.id" :movie="movie" />
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import MovieCard from '@/components/MovieCard.vue';
import { searchMovies } from '@/services/tmdb';
const route = useRoute();
const searchQuery = ref('');
const searchResults = ref([]);
const performSearch = async () => {
const response = await searchMovies(searchQuery.value);
searchResults.value = response.data.results;
};
watch(() => route.query.q, (newQuery) => {
searchQuery.value = newQuery;
performSearch();
}, { immediate: true });
</script>
You can find the full source code for this project on GitHub.
Top comments (0)