I have migrated my website to NUXT3

Published at 2025/05/21

I've recently motivated myself to check out what it looks like to work with NUXT3

Good starting point

Thanks to my current templating mechanism (which is really just an extensive usage of Template strings wrapped with helper methods) migrating main layout and templates to NUXT3 was a pretty easy task.

My next step was to create dedicated content collections (thanks to NUXT Content module):

// my content.config.ts file

import { defineContentConfig, defineCollection, z } from '@nuxt/content'

export default defineContentConfig({
  collections: {
    single: defineCollection({
      type: 'page',
      source: 'single/*.md'
    }),
    projects: defineCollection({
      type: 'page',
      source: 'projects/*.md',
      schema: z.object({
        title: z.string(),
        date: z.string(),
        draft: z.boolean(),
        category: z.array(z.string()),
        tags: z.array(z.string()),
        link: z.string(),
        changelog: z.string(),
        description: z.string(),
        repo: z.string(),
        npm: z.string(),
      })
    }),
    notes: defineCollection({
      source: 'notes/**/*.md',
      type: 'page',
      schema: z.object({
        title: z.string(),
        date: z.string(),
        tags: z.array(z.string()),
        category: z.array(z.string()),
        active: z.boolean(),
        draft: z.boolean(),
      })
    })
  }
})

Comments for the code above:

  • I've moved content files for my single-page static views (/, /about and /experience) to single collection.

  • Subpages that is using multiple markdown files (which are listed then in paginated/non-paginated manner) are defined as projects and notes collections.

Notes collection

As you might have noticed, my notes collection has a source path defined as notes/**/*.md - it's done like that because my blog notes are being stored per year basis (so that it's easier for me to maintain / add new entries - those lists tend to grow a lot sometimes!)

So my content folders looks like the following:

--/notes // folder
----/2015 // folder
------2015-01-10-markdown-file-1.md
------2015-09-01-markdown-file-2.md
------...
----/2016 //folder
------2016-03-22-markdown-file-1.md
------2016-07-15-markdown-file-2.md
------...
----/... //folders
----/2025 // folder
------2025-01-03-markdown-file-1.md
------2025-02-15-markdown-file-2.md
------2025-05-21-this-blog-post.md

Pagination component

As I've wanted to keep my website with as little dependencies as possible, I've developed a simple pagination component:

<template>
<ul class="pagination">
  <li 
    v-for="index in totalPages"
    :class="getPaginationClass(index)"
    :key="index"
  >
    <NuxtLink 
      :to="`${url}${index}/`"
    >{{ index }}</NuxtLink>
  </li>
</ul>
</template>
<script setup lang="ts">
const props = defineProps<{
  totalPages: number,
  currentPage: number,
  url: string,
}>()

const getPaginationClass = (pageIndex: number) => {
  return [{
    'current-page': pageIndex === props.currentPage
  }]
}
</script>

As you can see, thanks to url prop it's pretty flexible, reusable module that can be reused in other places on my website as well (although it's no needed for that now).

To handle notes pagination, I had to prepare following pages structure:

pages // folder
--notes // folder
----[...id].vue
----index.vue
----page // folder
------[page].vue

Thanks to such preparation, it is now possible to handle /notes/page/3/ url where there are listed appropriate entries.

[page].vue file looks like this:

<template>
  <div class="notes-list">
    <NuxtLink 
      v-for="post in posts" 
      :key="post.id"
      class="note-item" 
      :to="createUrl(post.title, 'notes')"
    >
      {{ post.title }} - {{ post.date }}
    </NuxtLink>
  </div>
  <div>
    <GenericPagination 
      :current-page="currentPage"
      :total-pages="totalPages"
      url="/notes/page/"
    />
  </div>
</template>
<script setup lang="ts">
import { paginationSize } from '~/constants/pagination';
import { createUrl } from '~/helpers/slugify'

definePageMeta({
  layout: 'notes',
  name: 'notes',
})

const route = useRoute()
const currentPage = parseInt(route.params.page as string || '1')
const start = (currentPage - 1) * paginationSize

const { data: posts } = await useAsyncData('currentPage', () => 
  queryCollection('notes')
    .order('date', 'DESC')
    .where('draft', 'IS NULL')
    .skip(start)
    .limit(paginationSize)
    .all()
)

const { data: total } = await useAsyncData('notes', () => 
  queryCollection('notes')
    .order('date', 'DESC')
    .where('draft', 'IS NULL')
    .all()
)

const totalPages = computed(() => 
  total?.value?.length ? Math.ceil(total.value.length / paginationSize) : 0
)
</script>

Because I've wanted to keep my urls backwards-compatible and not require full file paths to type (so /notes/<blog-entry-title-slug>/ and not /notes/<year>/<blog-entry-title-slug>) I've created a dedicated /pages/notes/[...id].vue layout (page) component:

<template>
  <div class="notes-index-wrapper">
    <h2 class="bebas">{{ page.title }}</h2>
    <p>Published at {{ page.date }}</p>
  </div>
  <div class="article-content-wrapper">
    <div class="main-container main-container--article">
      <article>
        <ContentRenderer 
          v-if="page" 
          :value="page" 
        />
      </article>
    </div>
  </div>
</template>
<script lang="ts" setup>
import { createUrl } from '~/helpers/slugify'

definePageMeta({
  layout: 'default'
})

const route = useRoute()
const { data: tempPage } = await useAsyncData(route.path, () => {
  return queryCollection('notes').all()
})

const page = tempPage.value?.find((obj) => createUrl(obj.title, 'notes') === route.path) || {
  title: '',
  date: '',
}
</script>

As you can see, for both, listing articles and rendering single one, I'm using my own createUrl helper method, which is also being used to find currently viewed article (as NUXT's auto route path won't find anything out of the box, as the urls are missing <year> folder path).

Just for your convenience, I'll share how createUrl method looks like:

export const slugify = (txt: string) => {
  const a = 'àáäâãåèéëêìíïîòóöôùúüûñçßÿœæŕśńṕẃǵǹḿǘẍźḧ·/_,:;'
  const b = 'aaaaaaeeeeiiiioooouuuuncsyoarsnpwgnmuxzh------'
  const p = new RegExp(a.split('').join('|'), 'g')
  return txt.toString().toLowerCase()
    .replace(/\s+/g, '-') // Replace spaces with
    .replace(p, c => b.charAt(a.indexOf(c))) // Replace special characters
    .replace(/&/g, '-and-') // Replace & with ‘and’
    .replace(/[^\w\-]+/g, '') // Remove all non-word characters
    .replace(/\-\-+/g, '-') // Replace multiple — with single -
    .replace(/^-+/, '') // Trim — from start of text .replace(/-+$/, '') // Trim — from end of text
}

export const createUrl = (title: string, collectionUrl: string) => `/${collectionUrl}/${slugify(title)}`

So.. Why my website is not rendered by NUXT already?

I'm switching projects next month and in the new one I'm gonna be using NUXT3 - so I've decided to give it a shot and try it out in advance (my previous NUXT experience was very limited and was with v2 at the time).

I've also checked on how NUXT3 is handling static site generation… And I'm not satisfied with its current solution - it looks and behaves a little bit like a Single Page Application, so it preloads only necessary code and then fetch the rest, and final DOM tree is not that clean either.. I've heard good stuff about on how it works on NUXTv2 though… But that's not the solution I'm looking forward to (I want something more up to date and future-proof) so in the end I'm staying with my homemade self-baked solution 🙂 (especially that after latest reimplementation it works really nice 👌)

I've also heard good things about Astro… Maybe next month I will give it a try? 🤔

Best,

-- ł.