·7 min read

Building the Post List Page

This article introduces how to create a blog post list page. It covers implementing the blog.vue page component along with PostList and PostCard components using Nuxt 3 features, setting up i18n, and applying the facade pattern to fetch data asynchronously.

blog.vue Page Component

We will develop the blog page component by separating it into a hero section and a content section, similar to the index page.

<script setup lang="ts">
  const posts = await getPosts()

  const { t } = useI18n()
  useSeoMeta({
    title: t('pages.blog.title'),
    description: t('pages.blog.description'),
  })
</script>

<template>
  <the-page inner-class="max-w-3xl">
    <blog-hero />
    <blog-post-list :posts="posts" />
  </the-page>
</template>

i18n

For multilingual files used on the blog page, values not specific to a single page were designed to be declared at the top level. For example, reading-time will also be used on the blog slug page, so it was defined at the top level.

pages:
  index:
    title: About.
    subtitle: This is Besfir's blog tutorial.
    description: Existing blog tutorials often fell short in terms of aesthetics for immediate use. To address this, I’ve created a tutorial that is minimalistic yet fully ready for use as a blog.
  blog:
    title: Posts
    subtitle: Records is the rule of memory.
    description: Memory can fade or sometimes become distorted over time. Keeping records helps complement those memories, allowing you to reflect on your growth and systematically accumulate your experiences.
    read: Read
reading-time: "{time} min read"

Fetching the List of Posts

When implementing asynchronous communication logic, I prefer using the Facade pattern. This approach hides complex implementation details and groups similar functionalities in one place, making maintenance much easier. Therefore, I will implement the data-fetching logic using Nuxt Content within a useRepository composable following the Facade pattern.

import type { BlogPost } from '~/types/BlogPost'

export const getPosts = async () => {
    const path = useRoute().path
    const { locale } = useI18n()
    const { data } = await useAsyncData(path, queryContent<BlogPost>('blog').locale(locale.value).find)
    return data as Ref<BlogPost[]>
}

Hero Component

Since this is quite similar to the index page, I will omit further explanation.

<template>
  <div class="pt-4 space-y-6 md:space-y-10">
    <h1 class="text-3xl md:text-5xl font-bold py-0.5 md:py-3 dark:text-white">
      {{ $t('pages.blog.subtitle') }}
    </h1>
    <p class="text-lg hidden md:block">
      {{ $t('pages.blog.description') }}
    </p>
  </div>
</template>

Post List Component

You need to pass the type of the markdown files as props to the PostList component, which displays the blog post list. However, since we’ll also include information such as creation date, modification date, and reading time, the default type cannot be used. Therefore, we will first define a BlogPost type by extending the MarkdownParsedContent type to support the correct structure, and then proceed to create the PostList component.

import type { MarkdownParsedContent } from '@nuxt/content'
import type { ReadTimeResults } from 'reading-time'

export interface BlogPost extends MarkdownParsedContent {
    createdAt: string
    updatedAt: string
    readingTime: ReadTimeResults
}

Blog Card Component

<script setup lang="ts">
  import type { BlogPost } from '~/types/BlogPost'

  interface Props {
    post: BlogPost
  }

  const props = defineProps<Props>()

  const { t, d } = useI18n()
  const readingTime = t('reading-time', { time: Math.ceil(props.post.readingTime.minutes || 1) })
  const formattedTime = d(props.post.createdAt, { key: 'short' })
</script>

<template>
  <article class="grid items-start grid-cols-4 gap-8">
    <div class="relative z-10 order-first mb-3 text-slate-500 dark:text-slate-400 flex-col hidden md:flex text-sm">
      <div class="flex flex-col">
        <time :datetime="post.createdAt">{{ formattedTime }}</time>
        <span>{{ readingTime }}</span>
      </div>
    </div>
    <div class="col-span-4 md:col-span-3">
      <div class="relative flex flex-col items-start group ">
        <div class="relative z-10 order-first mb-3 flex text-slate-500 dark:text-slate-400 md:hidden pl-3.5 text-sm">
          <span class="absolute inset-y-0 left-0 flex items-center py-1">
            <span class="h-full w-0.5 rounded-full bg-slate-200 dark:bg-slate-400" />
          </span>
          <div class="flex">
            <time :datetime="post.createdAt">{{ formattedTime }}</time>
            <span class="mx-1">·</span> <span>{{ readingTime }}</span>
          </div>
        </div>
        <div class="text-base font-semibold tracking-tight text-slate-800 dark:text-slate-100">
          <div class="absolute z-0 transition scale-95 opacity-0 -inset-y-6 -inset-x-4 bg-slate-50 group-hover:scale-100 group-hover:opacity-100 dark:bg-slate-800/50 sm:-inset-x-6 sm:rounded-2xl" />
          <nuxt-link-locale :to="post._path">
            <span class="absolute z-20 -inset-y-6 -inset-x-4 sm:-inset-x-6 sm:rounded-2xl" />
            <span class="relative z-10 text-xl">{{ post.title }}</span>
          </nuxt-link-locale>
        </div>
        <p class="relative z-10 flex-1 mt-2 prose prose-slate dark:prose-invert line-clamp-3 md:line-clamp-none">
          {{ post.description }}
        </p>
        <div class="relative z-10 flex items-center mt-4">
          <div class="flex items-center text-primary-500 dark:text-primary-400">
            <span class="font-medium">{{ $t('pages.blog.read') }}</span>
            <icon
                name="ph:arrow-right-bold"
                size="16"
                class="ml-1"
            />
          </div>
        </div>
      </div>
    </div>
  </article>
</template>
  • Line 11: The minute value is provided as a float. To make it more realistic, we’ll round it up for display.
  • Line 12: By combining the helper function d() for date formatting with the date format configured in i18n.config.ts, you can display dates in the desired format.

Project Structure So Far

Refer to this section for the file paths of the source code.

.
├── README.md
├── app.vue
├── assets
│   ├── css
│   │   └── main.pcss
│   └── icons
│       └── logo.svg
├── bun.lockb
├── components
│   ├── App
│   │   ├── Button.vue
│   │   ├── Link.vue
│   │   └── SnsLinks.vue
│   ├── Blog
│   │   ├── Hero.vue
│   │   ├── PostCard.vue
│   │   └── PostList.vue
│   ├── Index
│   │   ├── Hero.vue
│   │   └── Snaps.vue
│   └── The
│       ├── Footer.vue
│       ├── Header.vue
│       ├── LangSwitcher.vue
│       ├── Page.vue
│       └── ThemeSwitcher.vue
├── composables
│   └── useRepository.ts
├── constant
│   └── ButtonStyle.ts
├── content
│   ├── en
│   │   └── blog
│   │       └── 1.style guide.md
│   └── ko
│       └── blog
│           └── 1.style guide.md
├── eslint.config.mjs
├── i18n
│   ├── i18n.config.ts
│   └── locales
│       ├── en.yaml
│       └── ko.yaml
├── nuxt.config.ts
├── package.json
├── pages
│   ├── archives.vue
│   ├── blog.vue
│   └── index.vue
├── public
│   ├── _robots.txt
│   ├── favicon.ico
│   └── images
│       └── index
│           ├── hero.jpeg
│           ├── snap-1.jpg
│           ├── snap-2.jpg
│           ├── snap-3.jpg
│           ├── snap-4.jpg
│           └── snap-5.jpg
├── server
│   └── tsconfig.json
├── tailwind.config.ts
├── tsconfig.json
└── types
    ├── BlogPost.ts
    └── Button.ts