Introduction to Nuxt 3: Part 2
Movies App using TMDB API
Introduction
In this second part of “Introduction to Nuxt 3” we are going to build a Movie app where we can query the “The Movies Database” API.
You can also watch my YouTube video: {% embed https://youtu.be/utreBNrua44 %}
The code is available here.
Overview of App
We are going to build an application where we can search for a movie, fetch different results based on the query and then view more details about the movie in a separate page. We are going to use some of the concepts and features mentioned in Part 1.
Nuxt 3
The first step will be to generate a new Nuxt project:
npx nuxi init nuxt-movies-app
Make sure to cd nuxt-movies-app
and do npm install
.
The command will generate the starting code for a Nuxt 3 project. The folder structure should be like this:
Now if you run npm run dev
you should be able to go to https://localhost:3000
and see the default Nuxt Welcome Page.
Windi CSS
Windi CSS is the framework we are going to use for styling our app. Windi CSS is a utility-first CSS framework. It is very similar to Tailwind and can be regarded as an on-demand alternative to Tailwind Css.
Some of the benefits of using Windi CSS are the faster load times, full compatibility with Tailwind 2.0 classes, and many more. It also has integrations with Vite, Webpack, Nuxt, Vue CLI, etc.
Let’s install it with the following command.
npm i nuxt-windicss -D
Next, we need to update our nuxt.config.ts
with the following:
import { defineNuxtConfig } from 'nuxt/config'
export default defineNuxtConfig({
modules: [
'nuxt-windicss',
],
})
Great, we have Windi CSS now installed.
<aside>
💡 Much like Tailwind, Windi CSS also has a config file. Valid filenames that Windi can automatically pick up are: windi.config.ts
, windi.config.js
, tailwind.config.ts
and tailwind.config.js
.
</aside>
Example Windi Config:
import { defineConfig } from 'windicss/helpers'
export default defineConfig({
/* configurations... */
})
Replacing the default component
In the app.vue
let’s replace the default <NuxtWelcome>
component with something else like this:
<template>
<div>
Hello World
</div>
</template>
Layouts directory
The Nuxt app comes by default pretty bare bones. We can create the “magic” folders only if we need to. One of these magic folders is the layouts one. Nuxt provides a customizable layout system so that you can reuse it for multiple pages in your app.
Start by creating a
layouts/
folder.To enable the default layout create a
~/layouts/default.vue
file.
We will use this layout to place our Navigation Bar there.
<template>
<nav class="flex justify-center mt-10">
<NuxtLink class="px-4 py-2 border rounded-lg " to="/">Home</NuxtLink>
</nav>
<main>
<slot />
</main>
</template>
<aside>
We use the built-in
NuxtLink
component to create a link to our/
route.We are using Vue slots to place the content of the page right where the slot is.
</aside>
⚠️ We have one problem though, the /
route still points to our app.vue
file. That is because we haven’t created our ~/pages/
directory yet. Let’s create it.
Pages directory
Nuxt is smart enough to not include Vue Router if we don’t have a pages directory in our project. Let’s create it now:
Start by creating a
pages/
folder.Create
~/pages/index.vue
- this will correspond to the/
route.
We can also create a dynamic route for our movie details. The url we would like to create is this “nuxt-movies.com/movies/{id}” where {id}
is a dynamic value based on the movie’s id.
Start by creating a
/movies
folder under/pages
.In the
/movies
folder create a file named[id].vue
.
Notice how the id
is wrapped in square brackets. This means that it is dynamic and the route will have a param id
.
Changing app.vue
The last thing we need to do to make our routing work is to change our ~/app.vue
file. Currently it looks like this:
<template>
<div>
Hello World
</div>
</template>
Let’s change it to this:
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<NuxtLayout>
tells Nuxt to use our default layout and the <NuxtPage>
component tells Nuxt that we want to use the ~/pages
system.
Lastly, for the changes to take place we need to restart our dev server.
Server directory and catch-all route
Let’s create an endpoint that will communicate with TMDB API and expose it to our frontend.
As with the other magic folders we start by creating a /server
directory in our project root. Now let’s create /api
directory and inside that create /movies
directory where we need two files: search.js
and [...].js
.
The second one is called a Catch-all route, since it doesn’t have a name it will catch all routes that couldn’t be matched with an existing file in /server/api/movies
.
We will make the catch-all route to get a movie by id. Using the event
we can get the url and split it by /
so that the id
will be the last element of the array.
<aside> 💡 We don’t really need to use a catch-all route, but I am just showing it because in certain scenarious it can be useful.
</aside>
export default defineEventHandler((event) => {
const id = [...event.node.req.url.split("/")].pop();
const config = useRuntimeConfig();
return $fetch(`${config.apiBaseUrl}/movie/${id}`, {
headers: {
"Authorization": `Bearer ${config.apiKey}`
}
})
})
And for the search.js
we will use the following code:
export default defineEventHandler((event) => {
const {query} = getQuery(event);
const config = useRuntimeConfig();
return $fetch(`${config.apiBaseUrl}/search/movie?query=${query}`, {
method: "GET",
headers: {
"Authorization": `Bearer ${config.apiKey}`
}
})
})
In the above snippet we make use of getQuery()
to get the query from the event and then pass that to the API so we can search by that query.
Since the TMDB API requires authorization we set the header in both files to include our token. But instead of simply pasting it in the code, we can use the useRuntimeConfig()
hook to get it from our environment variables.
We should now go to nuxt.config.ts
and add this runtimeConfig
:
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
modules: [
'nuxt-windicss',
],
runtimeConfig: {
apiKey: '',
apiBaseUrl: '',
}
})
As you can see we set the apiKey
and apiBaseUrl
to be an empty string, that is fine because Nuxt is smart enough to look at the environment to see if we have provided a value and then actually set it for the whole app. Since the config is a file tracked by git, we should keep our secrets in an .env
file.
Adding .env
In order to have local environment working, we need to install the dotenv
npm package.
npm install dotenv
Now we can go to our project root and create a .env
file. Make sure to add it to .gitignore
if it isn’t added automatically.
In the .env
paste the following line:
NUXT_API_KEY=<YOUR_TMDB_TOKEN>
NUXT_API_BASE_URL=https://api.themoviedb.org/3
The name of the environment variable matters so Nuxt can detect it. We add the “NUXT” prefix and then separate the camelCase variable by undercase. So apiKey
becomes API_KEY
.
Adding types
Before we proceed with designing our main page, let’s create some types so that we know what kind of response we can expect from our API.
Let’s create a /types
folder in the root of our project. Now create an ApiResponse.ts
and a Movie.ts
file.
The API response file should be something like this. It contains information such as the results, current page, total number of results and total number of pages:
export type APIResponse = {
page: number;
results: Movie[];
total_pages: number;
total_results: number;
}
And also paste the following in Movie.ts
:
export type Movie = {
id: number;
title: string;
genres: {
id: number;
name: string
}[];
release_date: string;
runtime: number | null;
overview: string;
poster_path: string;
}
Designing the home page
Currently, our home page (~/pages/index.vue
) only contains the default layout and a <div>Home Page</div>
. We want to have a search bar and once we type something we want movies to appear below.
Let’s jump into it:
<template>
<div class="flex flex-col py-10">
<div>
<h2 class="text-2xl font-bold text-center">Nuxt Movies App</h2>
</div>
<div class="flex justify-center items-center h-32">
<input v-model="searchTerm" placeholder="Search" type="text" class="px-2 py-1 border border-gray-800 rounded-md min-w-64">
</div>
{{ searchTerm }}
</div>
</template>
<script setup lang="ts">
const searchTerm = ref('');
</script>
In the snippet above we add a simple heading as well as an <input>
for our Search bar. We center everything and style it with the help of Windi CSS.
We use the script setup
syntax and also use the auto-imports feature to import ref
and bind our searchTerm variable with the value of the input field.
Let’s now fetch some data and display it.
<template>
<div class="flex flex-col py-10">
<div>
<h2 class="text-2xl font-bold text-center">Nuxt Movies App</h2>
</div>
<div class="flex justify-center items-center h-32">
<input v-model="searchTerm" placeholder="Search" type="text" class="px-2 py-1 border border-gray-800 rounded-md min-w-64">
</div>
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 self-center gap-x-10 gap-y-10 mb-10">
<div v-for="movie in data?.results">
{{ movie.title }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ApiResponse } from '~/types/ApiResponse';
const searchTerm = ref('');
const url = computed(() => {
return `api/movies/search?query=${searchTerm.value}`;
});
const { data } = await useFetch<ApiResponse>(url)
</script>
As you can see, we use yet another useful Nuxt composable - useFetch()
. It exposes some fields like data
, error
, pending
, etc. We import our ApiResponse
type so that we get proper typing. Lastly, we make the url be computed, so that everytime it changes, the useFetch
will execute and refresh our data. In the template we display the movie titles in a grid using the v-for
directive from Vue.
Creating the Movie Card component
Let’s go to our project root and create another magical folder - ~/components
. This folder usually contains our components. They can be smaller sections of our website, that do not fall under the category of a full page. Let’s create a file named MovieCard.vue
:
<template>
<div class="h-128 w-64 border flex flex-col text-center ">
<div class="mb-5 bg-green-600 inline-block">
<img class="transform hover:translate-x-6 hover:-translate-y-6 delay-50 duration-100 inline-block" :src="imgURL" alt="Movie Poster">
</div>
<div class="text-lg">
{{ movie.title }}
</div>
<p class="text-m text-gray-500 break-words text-wrap truncate overflow-hidden px-2">
{{ movie.overview }}
</p>
</div>
</template>
<script setup lang="ts">
import { PropType } from 'vue';
import { Movie } from '~/types/Movie';
const props = defineProps({
movie: {
type: Object as PropType<Movie>,
required: true
}
})
const config = useRuntimeConfig();
const imgURL = computed(() => props.movie.poster_path != null ? `${config.public.imgBaseUrl}/${props.movie.poster_path}` : 'https://via.placeholder.com/300x500');
</script>
In the template we are showing the movie image, the title and the description.
In the script we use
defineProps()
to specify that amovie
prop will be passed to our component.We import also our
Movie
type from~/types/Movie.ts
.Lastly we use the
useRuntimeConfig
composable to get theimgBaseUrl
. This is necessary because the TMDB Api uses a different route for assets.
But we don’t have the imgBaseUrl
in our config, let’s add it now:
Update you nuxt.config.ts
like this:
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
modules: [
'nuxt-windicss',
],
runtimeConfig: {
apiKey: '',
apiBaseUrl: '',
// We use the public runtime config in
//order to expose this also to the client side
public: {
imgBaseUrl: '',
}
}
})
Finally, update the .env
with the image base URL: (Make sure to include NUXT_PUBLIC
prefix)
NUXT_API_KEY=<YOUR_TMDB_TOKEN>
NUXT_API_BASE_URL=http://api.themoviedb.org/3
NUXT_PUBLIC_IMG_BASE_URL=http://image.tmdb.org/t/p/w500
Let’s restart our dev server and we should end up with this:
Great! Now let’s make some optimisations and finish our app!
Adding debounce to search
If you open the Network tab in DevTools by pressing F12
and you start typing in our search box you will see we are making requests for every single letter we add or remove from the search promt. This is not optimal because it makes our server process all of the otherwise unnecessary requests.
We can fix this by giving our search bar a short debounce time. For that we can use one of the best Vue Composables libraries - VueUse.
npm i @vueuse/nuxt
And then update our nuxt.config.ts
file by adding VueUse to our modules:
export default defineNuxtConfig({
modules: [
'nuxt-windicss',
'@vueuse/nuxt'
],
runtimeConfig: {
...
}
})
Then in our index.vue
file:
<template>
...
</template>
<script setup lang="ts">
import { ApiResponse } from '~~/types/ApiResponse';
const searchTerm = ref('');
// Create a debounced version of searchTerm
const debouncedSearchTerm = refDebounced(searchTerm, 700);
// replace the url with the debounced version
const url = computed(() => {
return `api/movies/search?query=${debouncedSearchTerm.value}`;
});
const { data } = await useFetch<ApiResponse>(url)
</script>
VueUse has a great composable refDebounced
that we can use on our searchTerm
to give the user a bit of time to finish his query and then actually proceed with calling the API.
Make sure to also change the url
computed property to use the debouncedSearchTerm
.
Much better!
Adding some pagination
Another thing that we might want to add is a “Load more” button. Currently the TMDB API returns the first page of results. We can implement the button by having a local variable corresponding to the page and appending new results from the API to the current list.
Let’s start by updating our index.vue
page:
<template>
<div class="flex flex-col py-10">
<div>
<h2 class="text-2xl font-bold text-center">Nuxt Movies App</h2>
</div>
<div class="flex justify-center items-center h-32">
<input v-model="searchTerm" placeholder="Search" type="text" class="px-2 py-1 border border-gray-800 rounded-md min-w-64">
</div>
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 self-center gap-x-10 gap-y-10 mb-10">
<MovieCard :movie="movie" v-for="movie in data?.results" :key="movie.id"/>
</div>
<div v-if="data?.results.length" class="flex justify-center">
<button v-if="!disabledPrevious" @click="page--" class="px-4 py-2 text-m border rounded-lg">Previous</button>
<div class="px-4 py-2 text-m border rounded-lg">{{ page }}</div>
<button v-if="!disabledNext" @click="page++" class="px-4 py-2 text-m border rounded-lg">Next</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ApiResponse } from '~/types/ApiResponse';
const searchTerm = ref('');
const page = ref(1);
// Disable pagination depending on first or last page
const disabledPrevious = computed(() => {
return page.value === 1;
})
const disabledNext = computed(() => {
return page.value + 1 === data.value?.total_pages;
})
// Create a debounced version of searchTerm
const debouncedSearchTerm = refDebounced(searchTerm, 700);
// replace the url with the debounced version
const url = computed(() => {
return `api/movies/search?query=${debouncedSearchTerm.value}&page=${page.value}`;
});
const { data } = await useFetch<ApiResponse>(url)
</script>
A couple of things are happening here:
We added some buttons to act as pagination and they are only visible if there are results.
We created
page
which is aref
variable that holds our current page.We created two variables to disable our Previous button if it’s the first page and to disable our Next button if we are on the last page available.
Finally we changed our
url
to include also thepage
in the query.
In order for that last part to work, we also need to change our ~/server/api/movies/search.js
file:
export default defineEventHandler((event) => {
const {query, page} = getQuery(event);
const config = useRuntimeConfig();
return $fetch(`${config.apiBaseUrl}/search/movie?query=${query}&page=${page}&include_adult=false`, {
method: "GET",
headers: {
"Authorization": `Bearer ${config.apiKey}`
}
})
})
I also added the includeAdult
query and set it to false
so that we don’t get any NSFW content.
We should be able to now type something in the search bar and load different pages of movies.
Designing the details page
Maybe we want to see more details about a specific movie. Let’s create a new page. It will have a dynamic route.
In the
/pages
directory create a new directory called/movies
.In that new directory create a file
[id].vue
.
The brackets mean that this route will be dynamic. So if we go to /movies/55
we will see the details page for a movie with an id
of 55.
<template>
<div class="flex flex-col px-20 mt-10">
<div class="grid grid-cols-7 gap-1">
<img class="col-span-2" :src="imgUrl" alt="Movie Poster">
<div class="flex flex-col col-span-3">
<div class="text-4xl font-sans font-bold mb-5">" {{ data?.title }} "</div>
<div class="flex">
<div class="px-4 py-2 bg-gray-200 text-gray-800 rounded-full mr-2 mb-2" v-for="genre in data?.genres">{{ genre.name }}</div>
</div>
<div class="text-lg my-2 ">Release Date: {{ data?.release_date }}</div>
<div class="text-lg mb-2 ">Duration: {{ data?.runtime }} mins</div>
<p class="text-gray-600 text-m">{{ data?.overview }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Movie } from '~/types/Movie';
const route = useRoute();
const config = useRuntimeConfig();
const movieId = computed(() => route.params.id);
const imgUrl = computed(() => data.value?.poster_path ? `${config.public.imgBaseUrl}/${data.value?.poster_path}` : 'https://via.placeholder.com/300x500');
const {data} = await useFetch<Movie>(`/api/movies/${movieId.value}`);
</script>
We display more information about the movie such as
release_data
,genres
andruntime
.useRoute
is another auto-imported Nuxt composable that let’s us access the route and get theid
param.useRuntimeConfig
to get theimgBaseUrl
again or since some movies don’t have an image we show a placeholder.Similarly to
index.vue
we useuseFetch
to get the movie’s data.
Great so we have all this done and it’s looking awesome. One last thing we can optimise is that if we are in the details page and want to go back to the Home page, the state of our search is lost. Let’s fix that.
Preserving state when switching between routes
This chapter is a short one, don’t worry.
In order to preserve state between route changes we can use the keepalive
attribute in our app.vue
file.
<template>
<NuxtLayout>
<NuxtPage keepalive include="index" />
</NuxtLayout>
</template>
By adding this Nuxt will autowrap your pages with the <keep-alive>
Vue built-in component. You can specify pages in the include
or you can disable it for pages with the exlude
prop.
Congratulations
Congrats, you have made a fully functional Nuxt 3 app, utilizing some of the most important concepts and features of Nuxt and Vue.
Potential things that can be extended: Loading Spinners, Handling Errors.
Here are some useful resources that can come in handy:
💚 This was my first practical guide about Nuxt, I am going to be creating more Vue and Nuxt content, so if you liked this one please make sure to follow me. It means a lot!