DEV Community

Cover image for Introduction to Nuxt 3: Part 2
Alexander Gekov
Alexander Gekov

Posted on • Edited on

Introduction to Nuxt 3: Part 2

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:

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.

Overview 1

Overview 2

Nuxt 3

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

npx nuxi init nuxt-movies-app
Enter fullscreen mode Exit fullscreen mode

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

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
Enter fullscreen mode Exit fullscreen mode

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

import { defineNuxtConfig } from 'nuxt/config'

export default defineNuxtConfig({
  modules: [
    'nuxt-windicss',
  ],
})
Enter fullscreen mode Exit fullscreen mode

Great, we have Windi CSS now installed.

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

Example Windi Config:

import { defineConfig } from 'windicss/helpers'

export default defineConfig({
  /* configurations... */
})
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Hello World

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>
Enter fullscreen mode Exit fullscreen mode
  • 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.

⚠️  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>
Enter fullscreen mode Exit fullscreen mode

Let’s change it to this:

<template>
  <NuxtLayout>
    <NuxtPage />
  </NuxtLayout>
</template>
Enter fullscreen mode Exit fullscreen mode

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

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

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}`
        }
    })
})
Enter fullscreen mode Exit fullscreen mode

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}`
        }
    })
})
Enter fullscreen mode Exit fullscreen mode

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: '',
  }
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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

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>
Enter fullscreen mode Exit fullscreen mode
  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:

// 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: '',
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Let’s restart our dev server and we should end up with this:

Overview 3

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

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
Enter fullscreen mode Exit fullscreen mode

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

export default defineNuxtConfig({
    modules: [
    'nuxt-windicss',
    '@vueuse/nuxt'
  ],
  runtimeConfig: {
    ...
  }
})
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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

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>
Enter fullscreen mode Exit fullscreen mode

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:

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}`
        }
    })
})
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
  • 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.

<template>
  <NuxtLayout>
    <NuxtPage keepalive include="index" />
  </NuxtLayout>
</template>
Enter fullscreen mode Exit fullscreen mode

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!

Twitter

LinkedIn

YouTube

Top comments (0)