In this article, I will try to show you how to use Nuxt with an external API. I will try to do it by creating a basic shop app using Sylius as our API, without going too in-depth about building a frontend for our Nuxt app, but I will try to link any useful materials regarding that. For the purpose of this blog, we will be using Nuxt 3, if you want to learn more about data fetching on Nuxt 2, you should look into @nuxt/http or @nuxtjs/axios.

So, let’s start by explaining data fetching with Nuxt 3; we have a few built-in ways of fetching data:

  • $fetch – global function, which is a bit more user-friendly version of the base JavaScript fetch 
  • useAsyncData – composable, which allows us to pass any async function and await it on the client or server. It takes the return of our async function and returns it in the form of a reactive state and allows us to check if Promise is still pending.
const { data, pending } = await useAsyncData(async () => '');
  • useFetch – same as useAsyncData but takes a string containing a URL instead of a function, and it is equivalent to calling $fetch inside of useAsyncData
const { data, pending } = await useFetch('/products');

// same as code above
const { data, pending } = await useAsyncData(() => $fetch('/products'));

For a more detailed explanation of data fetching, you can check Nuxt docs.

So now, let’s make a basic page containing a list of products fetched from API. We will start by making a basic product component:

/components/Product.vue

<script setup lang="ts">
type Image = { path: string };

export type Product = {
  id: number;
  name: string;
  images: Image[];
};

const { name, images } = defineProps<Product>();
const coverImage = computed(() => images.at(0)?.path);
</script>

<template>
  <div class="product">
    <img :src="coverImage" :alt="name" />

    <p>{{ name }}</p>
  </div>
</template>

and then we will fetch products inside of our app.vue:

/app.vue

<script setup lang="ts">
import type { Product } from './components/Product.vue';

type ProductResponse = {
  'hydra:member': Product[];
};

const ITEMS_PER_PAGE = 18;

const page = ref(1);
// if you are using TypeScript useFetch is a generic function which can take a type as an argument which will be used as a type for data propery
const { data: productResponse } = await useFetch<ProductResponse>(
  'https://localhost:8000/api/v2/shop/products',
  {
    server: false,
	// quert params added at the end of our url
    query: {
      itemsPerPage: 18,
      page: 1,
    }
  }
);

const products = computed(() => productResponse.value?.['hydra:member']);
</script>

<template>
  <div class="products">
    <Product
      v-for="{ id, title, images } in products"
      :key="id"
      :id
      :title
      :images
    />
  </div>
</template>

Here is what our page looks like with some basic styling

As you can see here, we are using useFetch to fetch data from our API, useFetch returns an object containing data, which is a reactive property. You probably noticed that we passed two parameters to useFetch The second one is an object that allows us to configure the behavior of our fetch, here you can find more about it. For this fetch, we only used a query parameter to add a query to our URL, so instead of fetching from /api/v1/products, we are actually fetching from /api/v1/products?itemsPerPage=18&page=1. A thing worth mentioning here is that useFetch and useAsyncData, by default, run on the server and execute imminently on page entry; it can be controlled by passing respectively server: boolean and immediate: boolean in the second argument of these functions. Running useFetch on the server also means that page loading will be blocked until fetch completes.

Now let’s say I want to add pagination to our page. Our API allows us to do it by passing itemsPerPage and page in our query. We can start by making a simple pagination component:

/components/Pagination.vue

<script setup lang="ts">
type Props = {
  itemsPerPage: number;
  totalItems: number;
};

const { itemsPerPage = 1, totalItems = 1 } = defineProps<Props>();
const emit = defineEmits<{ change: [offset: number] }>();

const pages = computed(() =>
  Array.from({ length: Math.ceil(totalItems / itemsPerPage) }, (_, i) => ({
    key: `page-${i}`,
    number: i + 1,
  }))
);
const page = ref(0);

const changePage = (i: number) => {
  page.value = i;
  emit('change', pages.value[i].number);
};
</script>

<template>
  <div class="pagination">
    <button
      v-for="({ key }, i) in pages"
      :key
      :class="{ active: page === i }"
      @click="changePage(i)"
    >
      {{ i + 1 }}
    </button>
  </div>
</template>

and now we have to change some things in our app.vue:

/app.vue

<script setup lang="ts">
import type { Product } from './components/Product.vue';

type ProductResponse = {
  'hydra:member': Product[];
  'hydra:totalItems': number;
};

const ITEMS_PER_PAGE = 18;

const page = ref(1);
const { data: productResponse } = await useFetch<ProductResponse>(
  'https://localhost:8000/api/v2/shop/products',
  {
    server: false,
    query: {
      itemsPerPage: ITEMS_PER_PAGE,
      page,
    },
    watch: [page],
  }
);

const products = computed(() => productResponse.value?.['hydra:member']);
const totalItems = computed(() => productResponse.value?.['hydra:totalItems']);
</script>

<template>
  <div class="products-page">
    <div class="products">
      <Product
        v-for="{ id, name, images } in products"
        :key="id"
        :id
        :name
        :images
      />
    </div>

    <Pagination
      v-if="totalItems"
      :items-per-page="ITEMS_PER_PAGE"
      :total-items
      @change="page = $event"
    />
  </div>
</template>

What we did here is create a reactive page variable, and pass it inside watch to our useFetch so it reruns when we change page variable. Alternatively instead of passing a static object as our query we could also pass a reactive property to it, if we do it like that we can omit passing watch here. Passing any reactive property (e.g.: ref, computed) in an options object to useFetch will cause it to rerun on property change.

Here is how our pagination works:

Now we can change pages, but we have one problem: there is a delay between users pressing a button and fetching products. Something we should add here is some kind of loader to inform users that something is happening. So let’s do just that, starting by making a basic loader component:

<script setup lang="ts">
const { loading } = defineProps<{ loading: boolean }>();
</script>

<template>
  <span v-if="loading">Loading...</span>

  <slot v-else />
</template>

and here is how it is used in app.vue:

<script setup lang="ts">
import type { Product } from './components/Product.vue';

type ProductResponse = {
  'hydra:member': Product[];
  'hydra:totalItems': number;
};

const ITEMS_PER_PAGE = 18;

const page = ref(1);
// using pending from useFetch to check fetch state
const { data: productResponse, pending: loading } =
  await useFetch<ProductResponse>(
    'https://localhost:8000/api/v2/shop/products',
    {
      query: {
        itemsPerPage: ITEMS_PER_PAGE,
        page,
      },
      watch: [page],
    }
  );

const products = computed(() => productResponse.value?.['hydra:member']);
const totalItems = computed(() => productResponse.value?.['hydra:totalItems']);
</script>

<template>
  <div class="products-page">
    <div class="products">
      <Loader :loading>
        <Product
          v-for="{ id, name, images } in products"
          :key="id"
          :id
          :name
          :images
        />
      </Loader>
    </div>

    <Pagination
      v-if="totalItems"
      :items-per-page="ITEMS_PER_PAGE"
      :total-items
      @change="page = $event"
    />
  </div>
</template>

All we did here was take the pending property from useFetch and pass it to our loader, and that’s all there is to it. Here is how page changing looks now:

Now, I want to show you how to add some default configuration to our fetches. The thing we will try to configure here is a base path so we don’t have to type our whole URL for each request. We will achieve it with $fetch.create and a wrapper composable for useFetch:

// /composables/useApiFetch.ts

const baseURL = 'http://localhost:8000/api/v2/shop';

export const apiFetch = $fetch.create({ baseURL });

export const useApiFetch: typeof useFetch = (url, options) =>
  useFetch(url, { baseURL, ...options });

As you can see, the above code isn’t too complicated; what we do here is export two functions:

apiFetch – this function will work like base non-reactive $fetch but will use our basePath bye default. This function will be helpful when we want to do basic fetch without any need for reactive properties of useFetch.

useApiFetch – this function will work just like useFetch composable but will, by default, use our basePath.

Here, you can also configure default headers, methods, or any other property relevant to fetching. It’s a bit similar to the way you would configure defaults in axios. Additionally, you could make it so your baseURL is defined inside of .env, you can read more about it here.

After that, we can change previews useFetch to our useApiFetch, like in this example:

const { data: productResponse, pending: loading } =
  await useApiFetch<ProductResponse>('/products', {
    query: {
      itemsPerPage: ITEMS_PER_PAGE,
      page,
    },
    watch: [page],
  });

Now, I’d like to create a product page, we will use Nuxts file-based routing and layouts for this. I won’t go into too much detail here, but you can read more about it [here](https://nuxt.com/docs/guide/directory-structure/pages) and [here](https://nuxt.com/docs/guide/directory-structure/layouts). The first thing I did here was move the contents of our app.vue to /pages/index.vue, and change our app.vue to look like this:

/app.vue

<template>
  <NuxtLayout>
    <NuxtPage />
  </NuxtLayout>
</template>

I also created layouts/default.vue just to add a link back to the homepage, you can check it out on our Github.

Now, let’s create a dynamic route to display our product. We will do it by creating a file /pages/products/[code].vue. I won’t show the entire code here, but here is the bit we are most interested in:

const route = useRoute();

const code = computed(() => route.params.code);
const { data: product } = await useApiFetch<Product>(
  () => `/products/${code.value}`
);

As you can see above, we can now take the route.params.code and pass it inside a function to useFetch. Using a function here isn’t 100% necessary, but usually, when working with reactive URLs inside of useFetch it’s best to pass them inside arrow functions so useFetch can rerun when the URL changes. It is similar to the way we make computed properties.

Now we need a way of linking to our /products page, we will achieve this by adding NuxtLink to our product card component: 

<template>
  <!-- chang div to NuxtLink -->
  <NuxtLink :to="`/products/${code}`" class="product">
    <img :src="coverImage" :alt="name" />

    <p>{{ name }}</p>
  </NuxtLink>
</template>

And now, we can go to our product page:

For the final example, I would like to show you how to use `useFetch` to send a form. We will do that by creating a simple form for adding reviews, like this:

<script setup lang="ts">
const { product } = defineProps<{ product: string }>();

const form = reactive({
  email: '',
  title: '',
  rating: 1,
  comment: '',
});
const body = computed(() => ({ ...form, product }));

const {
  execute: addReview,
  status,
  error,
} = useApiFetch('/product-reviews', {
  body,
  watch: false,
  method: 'post',
  immediate: false,
});
</script>

<template>
  <span class="title">Add review form</span>

  <form
    @submit.prevent="() => addReview()"
    class="form"
    :class="{ loading: status === 'pending' }"
  >
    <UiInput
      v-for="(_, key) in form"
      v-model="form[key]"
      :key="`input-${key}`"
      :label="key"
      :="key === 'rating' && { type: 'number', min: 1, max: 5 }"
    />

    <button>add review</button>

    <p v-if="status === 'error'" class="error">{{ error?.data.detail }}</p>
    <p v-if="status === 'success'" class="success">
      review was added successfully
    </p>
  </form>
</template>

One thing I want to mention before we begin is that this isn’t the best way of making forms. This form doesn’t really have any proper validation. For this example, I actually wanted our fetches to error, but if you want to look more into form validation, you may want to check out VeeValidate

The thing I want to focus on the most here is the options we passed to useFetch, so we will go through those in order:

body – this is just a way for us to pass the body to our request, but that isn’t the interesting thing here. The important thing here is that body is a reactive property in this case computed, and if we pass a reactive property as an option to useFetch it will rerun when it changes.

watch – usually, it is used to pass an array of reactive properties to watch. The thing we did here is pass false to it, and what it will do is stop fetch from rerunning when the body updates. The reason we are doing it is because we want our fetch only to run when we call execute (renamed to: addReview). Additionally you might want to pass a body like this body: body.value but if you do it like this, the value inside of the body wants update on execute, and will always be passed as its initial state.

method – just a way to pass a specific method for our request, in this case, post.

immediate – this will stop our fetch from running on page load, but it will also cause our pending property to be equal to true until we fetch some data. Unfortunately this behavior of pending isn’t great for us here, that is why we had to use status here. By using status we can check loading state like this status === 'pending', and it also has a way for us to check for error, success and idle statuses. You can see the reasoning behind status here.

This example would be the last one I had prepared for this blog, but this isn’t everything there is to Nuxt and APIs. Here is a list of things I think are important but I didn’t have space to fit into this blog:

  •  Authentication – it is really important when working with users, and securing user data. For this I would advise to look into these packages: Auth.jsnuxt/auth or check some official Nuxt modules.
  •  State management – for more advanced store base state management, I would recommend Pinia or its module for Nuxt.
  •  UI library – most front-end apps need UI for Vue/Nuxt. I like using shadcn-vue, but if Tailwind isn’t your thing Nuxt has some great UI modules.
  •  API platform type gen – this point is mostly valid for Sylius or any other API platform-based back-end. API platform provides a generator for Nuxt starter app and cli for generating TypeScript types.
  • Building your own API – this blog is mostly about working with an external API, but with Nuxt you can build your own API. Here are some useful links regarding that:

Summary

As you can see, Nuxt gives us a lot of power when it comes to working with API, this can be further enhanced by external packages and Nuxt modules. With this article, I had an intention of showing modern data fetching patterns presented in Nuxt 3, and the usage of those patterns from building modern web apps. I hope that this article was helpful, and I hope that through it, you gained more understanding of Nuxt and its patterns. All the code seen in this blog is available on our Github