The Repository Pattern in Nuxt: How to Future-Proof Your Data Layer

Your Nuxt app needs to use the Repository Pattern (or at least something like it). If you don't, and build your app the naive way, you'll be coupled to your database. And that will cause you *so* much pain down the road.

Michael Thiessen
Nuxt 3

The Mastering Nuxt FullStack Unleashed Course is here!

Get notified when we release new tutorials, lessons, and other expert Nuxt content.

Click here to view course

With the Repository Pattern, your database and server routes will be so neatly decoupled and separated that it will make it a breeze to:

  • Add new server routes that access similar data without duplicating complex queries
  • Update your database schema without breaking a million things all over your app
  • Or completely swap out your data layer entirely (in memory -> file storage -> Postgres or whatever you like)
// Before: Mixed concerns in route
export default defineEventHandler(async (event) => {
  const chats = await database.query('SELECT * FROM chats')
  // Route knows about database details!
})

// After: Clean separation
export default defineEventHandler(async (event) => {
  const chats = await getAllChats()
  // Route just calls repository method
})

Before, your server routes contain all sorts of database-specific logic.

After, there's a clean separation of concerns which makes maintaining your app so much better.

Now that I've convinced you, let's dive into how we actually implement this in a Nuxt app.

The Core Implementation of the Repository Pattern

First, you'll need to create a new folder inside your server/ directory: server/repository/.

Inside, you'll start with one file per domain (eg. chatRepository.ts, projectRepository.ts). You'll split these files up into more focused files as you need to.

Your file structure will look something like this:

server/
├── api/
│   ├── chats/
│   │   └── index.get.ts
│   └── projects/
│       └── index.get.ts
└── repository/
    ├── chatRepository.ts
    └── projectRepository.ts

Taking it a step further, we can organize these into separate layers to make this even more modular:

layers/
├── chats/
│   └── server/
│       ├── api/
│       │   └── chats/
│       │       └── index.get.ts
│       └── repository/
│           └── chatRepository.ts
├── projects/
│   └── server/
│       ├── api/
│       │   └── projects/
│       │       └── index.get.ts
│       └── repository/
│           └── projectRepository.ts

Here, we're creating layers based on domain, following a domain-driven-design approach.

Outlining the Repository Files

Once you have the main structure set up, you'll implement each of the files.

Here, you really want to focus on making sure your API is consistent and well thought out. This is the part that you won't be able to change easily, and getting this part right will make swapping out implementations a breeze in the future.

I like to follow these rules:

  • Export async functions for all CRUD operations (create, read, update, delete)
  • Handle data storage and retrieval (however you choose to do this, in memory, Redis, a database, etc.)
  • Add in any necessary business logic or data transformations

Your interface will look something like this:

// chatRepository.ts
export async function getAllChats(): Promise<Chat[]> { }
export async function getChatById(id: string): Promise<Chat | null> { }
export async function createChat(data: CreateChatData): Promise<Chat> { }
export async function updateChat(id: string, data: UpdateChatData): Promise<Chat | null> { }
export async function deleteChat(id: string): Promise<boolean> { }

In the chatRepository.ts file we have all the main CRUD operations for the chat resource:

  • get all chats
  • get a specific chat by id
  • create a new chat
  • update an existing chat
  • delete a chat

Before we get to actually implementing these server routes, we'll first see how we'll use these repository methods in our server routes so we get the full end-to-end picture.

Using Repository Methods in Nuxt Server Routes

In our server routes, all we need to do is import and call these functions:

// server/api/chats/index.get.ts
import { getAllChats } from '~/server/repository/chatRepository'

export default defineEventHandler(async (event) => {
  return await getAllChats()
})

Our server routes are no longer concerned with the details of how the chat data is stored, how to fetch it, or what kind of transformation might be needed to send our response back.

The only things our server routes need to deal with now are all of the HTTP method stuff. Parsing the body or parameters from the query, checking or setting cookies, catching and handling errors with the right status codes, and so on.

This makes our server routes just a thin layer that allows our actual business logic (in the repository files) be accessed through a web server. This also means we could easily build any kind of interface we want for this repository layer, like a CLI tool or Nitro task.

Example: Rewriting Your Data Layer

Let's say that our current implementation of getAllChats is just in-memory, because we wanted to keep things really simple:

const chats: Chat[] = []

export async function getAllChats(): Promise<Chat[]> {
  return chats
}

Obviously, this doesn't make for a great app, since every server restart completely resets everything!

So, we refactor to use unstorage which is built in to Nuxt through the useStorage composable:

export async function getAllChats(): Promise<Chat[]> {
  const storage = useStorage('chats')
  const keys = await storage.getKeys()
  const chats = await Promise.all(
    keys.map(key => storage.getItem(key))
  )
  return chats
}

Aside from some configuration to set up unstorage, this is all that we have to do, because we've nicely abstracted this away.

Our server route for listing all chats stays exactly the same, because we didn't change the interface of this method, only the internal implementation details. And, if we're using this method in other server routes (which we probably are), we've saved ourselves a ton of headache already.

We can even completely change how we store our chats, as long as we don't change the method interface:

// Store all chats in one object: { [id: string]: Chat }
export async function getAllChats(): Promise<Chat[]> {
  const storage = useStorage('data')
  const chatsObject = await storage.getItem('chats') || {}
  // Convert object to array
  return Object.values(chatsObject)
}

Conclusion

Through the Repository Pattern here, we've neatly encapsulated all of our business logic and data storage into it's own layer, making maintenance and adding new features so much easier.

Because all of the data access is in a few files, we have very little to update if we want to completely swap out our database or change our database schema.

And writing new server routes is now only about stringing together repository methods, once you've parsed your inputs and handled all the errors.

Michael Thiessen
Michael is a passionate full time Vue.js and Nuxt.js educator. His weekly newsletter is sent to over 11,000 Vue developers, and he has written over a hundred articles for his blog and VueSchool.

Follow MasteringNuxt on