Introduction to Nuxt 3: Part 2
Tags: vue, javascript, beginners, tutorial
Published: 2023-01-30 12:07:34

## 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](https://github.com/alexander-gekov/nuxt-movies-app-tutorial).

## 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.

![Overview 1](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/yacv9cl0q6i1und4dxwo.png)

![Overview 2](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6xp6rqfi4ot1ev5xemj0.png)

## Nuxt 3

The first step will be to generate a new Nuxt project:

```shell
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:

![Project Structure](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bzqruv6xbuy1rrej08nd.png)

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.

```shell
npm i nuxt-windicss -D
```

Next, we need to update our `nuxt.config.ts` with the following:

```tsx
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: 

```tsx
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:

```vue
<template>
  <div>
    Hello World
  </div>
</template>
```

![Hello World](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/v5fl7emvv975ojbqgeev.png)

## 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.

```vue
<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:

```vue
<template>
  <div>
    Hello World
  </div>
</template>
```

Let’s change it to this:

```vue
<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>

```tsx
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:

```tsx
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`:

```tsx
// 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.

```shell
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:

```typescript
export type APIResponse = {
    page: number;
    results: Movie[];
    total_pages: number;
    total_results: number;
}
```

And also paste the following in `Movie.ts`:

```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:

```vue
<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.

```vue
<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.

![Loop over movies](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/w6pks5hfs2p4gdme6hql.png)

## 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`:

```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>
```

1. In the template we are showing the movie image, the title and the description.
2. In the script we use `defineProps()` to specify that a `movie` prop will be passed to our component.
3. We import also our `Movie` type from `~/types/Movie.ts`.
4. Lastly we use the `useRuntimeConfig` composable to get the `imgBaseUrl`. 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:

```tsx
// 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:

![Overview 3](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gxqp24gt96v4pe7q1hf0.png)

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. 

![No debounce](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7zcy13fa21ezoro9hbvm.png)

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. 

```shell
npm i @vueuse/nuxt
```

And then update our `nuxt.config.ts` file by adding VueUse to our modules:

```tsx
export default defineNuxtConfig({
    modules: [
    'nuxt-windicss',
    '@vueuse/nuxt'
  ],
  runtimeConfig: {
    ...
  }
})
```

Then in our `index.vue` file:

```vue
<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`.

![With debounce](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/dz7c0c85zae6z9rowhre.png)

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:

```vue
<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 a `ref` 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 the `page` in the query.

In order for that last part to work, we also need to change our `~/server/api/movies/search.js` file:

```tsx
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.

```vue
<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` and `runtime`.
- `useRoute` is another auto-imported Nuxt composable that let’s us access the route and get the `id` param.
- `useRuntimeConfig` to get the `imgBaseUrl` again or since some movies don’t have an image we show a placeholder.
- Similarly to `index.vue` we use `useFetch` 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.

```vue
<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:

* [The Intuitive Web Framework](https://nuxt.com/)

* [VueUse](https://vueuse.org/)

* [Windi CSS](https://windicss.org/)

* [The Movie Database (TMDB)](https://www.themoviedb.org/)

💚  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!

[Twitter](https://twitter.com/AlexanderGekov)

[LinkedIn](https://www.linkedin.com/in/aleksandar-gekov/)

[YouTube](https://www.youtube.com/channel/UCAARceUUB_X6xsgeRBRg16A)