Nuxt 3 | Repository pattern: organising and managing your calls to APIs (Typescript)

Luiz Eduardo Zappa
9 min readJul 16, 2023

--

In Nuxt 2 the use of axios was straightforward and allowed a good management of API calls. When I started to develop with Nuxt 3 I found it difficult to organise with the composables useFetchand useAsyncData(which uses ofetchlibrary behind).

This article is a step by step on how to use the repository pattern to organise your API calls in Nuxt 3. Looking for an example, I just found this article from Vue Mastery which is very enlightening, but has some problems, like generating duplicate requests when using SSR (Server-Side Rendering). I based this article to write this tutorial correcting the flaws I found.

Accessing API in a clean way

The repository pattern

Before we go into the details of the implementation, a short theoretical introduction to this pattern is important for those who are not familiar with it.

The repository pattern is a design pattern that provides an abstraction layer between the application’s business logic and the data persistence layer (typically a database or web service).

The main idea behind the repository pattern is to encapsulate the logic for retrieving and storing data within dedicated repository classes. These repositories act as a bridge between the application and the datasource, providing consistent and uniform interface to interact whit the data.

To avoid getting too ethereal, take a look at the diagram below:

Without VS With repository pattern

In the first diagram this pattern is not used. All the logic to access (and transform) the source data is in the application layer. So, if there is a change in the URL or in the format that the data comes from, we will have to make the same change in all pages that consume that endpoint, hurting the DRY (don’t repeat yourself) principle .

The second diagram introduces the repository layer between the application and the datasources. All the logic of accessing (and transforming) the data is encapsulated in this layer, so the application layer does not need to know the implementation details of accessing the datasource.

This provides some advantages to our code:

  • Abstraction: shields the application from the underlying data access implementation details. It allows the application to work with a consistent set of methods and operations, regardless of the specific database technology.
  • Separation of concerns: by isolating the data access logic into dedicated repository classes, this pattern helps maintain a separation of concerns between the business logic and data persistence concerns. This improves code readability, maintainability and testability.
  • Single responsibility principle: each repository class focuses on a specific entity within the domain model.
  • Testability: repositories classes can be easily mocked or stubbed during unit testing.

Implementing the repository pattern in Nuxt 3

All code below is in this GitHub repository. The datasource will be a fake API so we don’t have to worry about building an API web service or a database.

As we will be using the $fecth method of the ofetch library, we have to install it to be able to import it. In your terminal install the library and save in development:

npm install ofetch --save-dev

The repository layer will be in the repository folder. I’ll follow the same organisation as the Vue Mastery tutorial, creating a factory class at the root folder and for each repository, a new file will be created inside the modules subfolder.

So, let’s go, in the root folder of your Nuxt 3 project create the repository folder:

mkdir repository

Inside this folder we create the factory.ts file which will have our abstract class that all repositories will extend from:

// [FILE]: repository/factory.ts

// 3rd's
import { $Fetch, FetchOptions } from 'ofetch';

/*
The FetchFactory acts as a wrapper around an HTTP client.
It encapsulates the functionality for making API requests asynchronously
through the call function, utilizing the provided HTTP client.
*/
class FetchFactory<T> {
private $fetch: $Fetch;

constructor(fetcher: $Fetch) {
this.$fetch = fetcher;
}

/**
* The HTTP client is utilized to control the process of making API requests.
* @param method the HTTP method (GET, POST, ...)
* @param url the endpoint url
* @param data the body data
* @param fetchOptions fetch options
* @returns
*/
async call(
method: string,
url: string,
data?: object,
fetchOptions?: FetchOptions<'json'>
): Promise<T> {
return this.$fetch<T>(
url,
{
method,
body: data,
...fetchOptions
}
)
}
}

export default FetchFactory;

This class receives an HTTP client that will be used to make the requests. In this case we are using the $fetch method from the ofetch library, which is the one Nuxt 3 uses behind the scene.

Now let’s create the repositories. They will be inside the modules subfolder, so create the modules folder inside the repository folder:

mkdir modules

For simplicity, in this example we will use only one domain: products. Then we will have only one repository. But the idea is that for each domain (example: users, cart, …) a separate repository is created.

Let’s create the product repository. Create the products.ts file inside the modules subfolder.

// [FILE]: repository/modules/products.ts

// 3rd's
import { FetchOptions } from 'ofetch';
import { AsyncDataOptions } from '#app';

// locals
import FetchFactory from '../factory';

type IProduct = {
id: number;
title: string;
price: number;
description: string;
category: string;
image: string;
rating: {
rate: number;
count: number;
}
}

class ProductsModule extends FetchFactory<IProduct[]> {
private RESOURCE = '/products';

/**
* Return the products as array
* @param asyncDataOptions options for `useAsyncData`
* @returns
*/
async getProducts(
asyncDataOptions?: AsyncDataOptions<IProduct[]>
) {

return useAsyncData(
() => {
const fetchOptions: FetchOptions<'json'> = {
headers: {
'Accept-Language': 'en-US'
}
};
return this.call(
'GET',
`${this.RESOURCE}`,
undefined, // body
fetchOptions
)
},
asyncDataOptions
)
}
}

export default ProductsModule;

We create the product repository by extending the FetchFactory class. Notice that we wrap the call method with the composable useAsyncData. This is necessary so that we don’t have network calls duplication (see Nuxt documentation).

As an example I added the variable fetchOptions in case some method needs additional parameters, in the example I modified the header. Also, it is possible to pass options to the useAsyncData function when calling this method. As we will see, this will be useful later on.

Now let’s manage our respositories through a Nuxt plugin. To do this, inside the plugins directory of our Nuxt project, create a file called api.ts:

// [File]: plugins/api.ts

// 3rd's
import { $fetch, FetchOptions } from 'ofetch';

// locals
import ProductsModule from '~/repository/modules/products';

interface IApiInstance {
products: ProductsModule;
}

export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig();

const fetchOptions: FetchOptions = {
baseURL: config.public.apiBaseUrl
};

// Create a new instance of $fecther with custom option
const apiFecther = $fetch.create(fetchOptions);

// An object containing all repositories we need to expose
const modules: IApiInstance = {
products: new ProductsModule(apiFecther),
};

return {
provide: {
api: modules
}
};
});

Here we create an instance of $fetch called apiFetcher and pass it to our product repository constructor. The base url value comes from an environment variable, let’s add it to our project.

In the root directory of our Nuxt project create a file called .env which will contain the base URL of our api.

# [File]: .env

API_BASE_URL=https://fakestoreapi.com

Now we need to pass this variable when we start Nuxt. To do this, in the package.json file change the dev script to the following:

[FILE]: package.json

"scripts": {
...
"dev": "nuxt dev --dotenv .env",
...
}

In your terminal run the command below so that Nuxt generates the type files and typescript no longer complains about the baseURL attribute of our plugin.

npm run postinstall

Then we need to expose the API base URL variable in the Nuxt settings. In the nuxt.config.ts file add the following snippet:

// [FILE]: nuxt.config.ts

// ...

runtimeConfig: {
public: {
apiBaseUrl: process.env.API_BASE_URL
}
}

// ...

Okay, now we will use this plugin on a Nuxt page. Inside the pages directory I will create the index.vue file:

// [FILE]: pages/index.vue

<template>
<h1>Products list</h1>
<div
v-if="pending"
class="spinner-wrapper"
>
<span class="loader"></span>
</div>
<div
v-else
class="product-wrapper"
>
<div
v-for="product in productsList"
:key="product.id"
class="card"
>
<div class="title">{{ product.title }}</div>
<div class="thumbnail"><img :src="product.image"></div>
<div class="description">{{ product.description }}</div>
<div class="wrapper-meta">
<span class="price">${{ product.price }}</span>
<span class="rate">☆ {{ product.rating.rate }}</span>
</div>
</div>
</div>
</template>

<script setup lang="ts">
const { $api } = useNuxtApp();

const {
data: productsList,
pending,
error
} = await $api.products.getProducts();
</script>

<style lang="css" scoped>
:root {
--card-height: 324px;
--card-width: 288px;
--spinner-size: 14px;
--spinner-color: gray;
}

.spinner-wrapper {
display: flex;
flex-wrap: wrap;
flex-direction: column;
gap: 3rem;
align-items: center;
}

.loader {
color: var(--spinner-color);
font-size: var(--spinner-size);
width: 1em;
height: 1em;
border-radius: 50%;
position: relative;
display: block;
text-indent: -9999em;
animation: mulShdSpin 1.3s infinite linear;
transform: translateZ(0);
}

@keyframes mulShdSpin {
0%,
100% {
box-shadow: 0 -3em 0 0.2em, 2em -2em 0 0em, 3em 0 0 -1em, 2em 2em 0 -1em,
0 3em 0 -1em, -2em 2em 0 -1em, -3em 0 0 -1em, -2em -2em 0 0;
}
12.5% {
box-shadow: 0 -3em 0 0, 2em -2em 0 0.2em, 3em 0 0 0, 2em 2em 0 -1em,
0 3em 0 -1em, -2em 2em 0 -1em, -3em 0 0 -1em, -2em -2em 0 -1em;
}
25% {
box-shadow: 0 -3em 0 -0.5em, 2em -2em 0 0, 3em 0 0 0.2em, 2em 2em 0 0,
0 3em 0 -1em, -2em 2em 0 -1em, -3em 0 0 -1em, -2em -2em 0 -1em;
}
37.5% {
box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, 3em 0em 0 0, 2em 2em 0 0.2em,
0 3em 0 0em, -2em 2em 0 -1em, -3em 0em 0 -1em, -2em -2em 0 -1em;
}
50% {
box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, 3em 0 0 -1em, 2em 2em 0 0em,
0 3em 0 0.2em, -2em 2em 0 0, -3em 0em 0 -1em, -2em -2em 0 -1em;
}
62.5% {
box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, 3em 0 0 -1em, 2em 2em 0 -1em,
0 3em 0 0, -2em 2em 0 0.2em, -3em 0 0 0, -2em -2em 0 -1em;
}
75% {
box-shadow: 0em -3em 0 -1em, 2em -2em 0 -1em, 3em 0em 0 -1em,
2em 2em 0 -1em, 0 3em 0 -1em, -2em 2em 0 0, -3em 0em 0 0.2em,
-2em -2em 0 0;
}
87.5% {
box-shadow: 0em -3em 0 0, 2em -2em 0 -1em, 3em 0 0 -1em, 2em 2em 0 -1em,
0 3em 0 -1em, -2em 2em 0 0, -3em 0em 0 0, -2em -2em 0 0.2em;
}
}

.product-wrapper {
display: grid;
grid-template-columns: repeat(auto-fit, calc(350px));
grid-gap: 32px;
justify-content: center;
padding: initial;
}

.card {
background: #fff;
border-radius: 19px;
box-shadow: 20px 20px 60px #d9d9d9, -20px -20px 60px #fff;
margin: 20px;
padding: 1rem;
text-align: center;
display: flex;
flex-direction: column;
height: var(--card-height);
width: var(--card-width);
overflow: hidden;
position: relative;
font-family: sans-serif;
font-size: 14px;
font-weight: 400;
}

.card .title {
font-weight: 700;
color: rgb(197, 131, 8);
}

.card .thumbnail {
align-items: center;
display: flex;
justify-content: center;
padding-top: 10px;
padding-left: 30px;
padding-right: 30px;
}

.card .thumbnail > img {
height: 150px;
object-fit: contain;
}

.card .description {
margin-top: 1rem;
font-size: 13px;
}

.card .wrapper-meta {
display: flex;
flex-direction: row;
justify-content: space-around;
font-size: 14px;
font-weight: 700;
margin-top: 2rem;
}
</style>

The relevant part of this code is inside the script tag. We take the reference to the created plugin ( $api) and call the getProductsmethod. The values returned are those of the composable useAsyncData (see documentation). They are already reactive by default.

That way API is initially called on the backend, what if we wanted it to be called only on the client side? For that, we can set the server option to false:

// To API be called only on the client side

const {
data: productsList,
pending,
error
} = await $api.products.getProducts({
server: false
});

Attention: the use of the composable useAsyncData inside the onMounted hook will only work when a hot reload of the page happens. To work around this behaviour we should also set the server option to false, as in the previous example.

All the code is in this GitHub repository.

Conclusion

We were able to abstract all datasource access logic from the application layer. For example, if we wanted to replace the fake API, we could just change it in the repository and our application would be unchanged (as long as we respect the contract).

The repository pattern is also widely used in the backend when building REST API services that access databases, for example.

--

--

Luiz Eduardo Zappa

Engineer breaking into the world of information technology 👨‍💻 I comment on what I'm developing on https://twitter.com/imluizzappa