·9 min read

Building the Post Page

The post page is a screen that displays blog content individually, serving as a core space for interaction with users. It includes the title, author, body, images, and comments. In this guide, we will create a post page, customize an image component, and even build a gallery component.

Blog Page Hierarchy

The post page is structured using a dynamic parameter called a slug. Depending on the author's preference, all posts can be placed under the blog directory or organized by year, such as blog/2025. To meet these requirements and ensure flexible scalability, we’ll use a Catch-all Route.

Since this Catch-all Route should only work under the /blog URL structure, we’ll update the directory structure as follows:

.
├── pages
│   ├── archives.vue
│   ├── blog.vue
│   └── index.vue

Fetching Posts

We will enhance the previously implemented useRepository() function to fetch posts. The data property of the useAsyncData() response object is of the nullable type <T> | null. However, I will handle cases where the data object is empty by returning a 404 error and ensuring that the type always resolves to <T>.

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

const _fetchContent = async <T>(
    queryFn: () => Promise<T>,
) => {
    const path = useRoute().path
    const { data } = await useAsyncData(path, queryFn)

    return data as Ref<T>
}

export const getPosts = () => {
    const { locale } = useI18n()
    return _fetchContent(
        queryContent<BlogPost>('blog').locale(locale.value).find,
    )
}

export const getPostBySlug = async () => {
    const { locale, t } = useI18n()
    const data = await _fetchContent(
        queryContent<BlogPost>('blog', ...(useRoute().params.slug as string))
            .locale(locale.value)
            .findOne,
    )
    if (!data.value) {
        throw createError({ statusCode: 404, message: t('pages.error.404') })
    }
    return data
}

Multilingual Text

We’ll add multilingual text to display the last modified date.

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
  slug:
    updatedAt: "Updated at {time}"
  error:
    404: Page not founded.
reading-time: "{time} min read"

[...slug].vue Page Component

The page component is named [...slug].vue to function as a Catch-all Route.

<script setup lang="ts">
const post = await getPostBySlug()
useContentHead(post)

const { d, t } = useI18n()

const postedTime = computed(() => {
  if (post.value.updatedAt) {
    return {
      iso: post.value.updatedAt,
      formatted: t('pages.slug.updatedAt', { time: d(post.value.updatedAt, { key: 'short' }) }),
    }
  }
  return {
    iso: post.value?.createdAt,
    formatted: d(post.value.createdAt, { key: 'short' }),
  }
})
</script>

<template>
  <the-page
    inner-class="max-w-3xl"
    class="pt-4 md:pt-8 pb-10"
    is-prose
  >
    <main>
      <div class="flex justify-between items-center mb-7">
        <div class="relative text-slate-500 dark:text-slate-400 pl-3.5">
          <div class="absolute inset-y-0 left-0 py-1">
            <div class="h-full w-0.5 rounded-full bg-slate-200 dark:bg-slate-500" />
          </div>
          <div class="flex text-base">
            <time :datetime="postedTime.iso">{{ postedTime.formatted }}</time>
            <span class="mx-1">·</span>
            <span>{{ $t('reading-time', { time: Math.ceil(post.readingTime.minutes || 1) }) }}</span>
          </div>
        </div>
      </div>
      <h1>{{ post.title }}</h1>
      <content-renderer :value="post" />
    </main>
  </the-page>
</template>
  • Line 3: The useContentHead() function is a helpful utility provided by the Nuxt Content module. It takes ParsedContent type data to simplify SEO configurations.
  • Lines 7–18: The Posted Time prioritizes showing the last modified time, falling back to the created time if no modified time is available. Since freshness significantly impacts the value of content in the development field, this approach clearly communicates how up-to-date the content is.

Customizing Heading Components

We will remove the default border styles from H2–H4 tags and customize them to display a hash character when hovered.

<template>
  <h2 :id="id">
    <a
        v-if="id && generate"
        :href="`#${id}`"
        class="-ml-2 pl-2 border-none group relative leading-none"
    >
      <span
          class="absolute -ml-[38px] hidden items-center border-0 opacity-0 group-hover:opacity-100 group-focus:opacity-100 lg:flex"
      >
        <span
            class="flex h-[30px] w-[30px] items-center justify-center rounded-md text-slate-400 shadow-sm ring-1 ring-slate-900/5 hover:text-slate-700 hover:shadow hover:ring-slate-900/10 bg-white dark:bg-slate-800 dark:text-slate-400 dark:shadow-none dark:ring-0 dark:hover:bg-slate-700 dark:hover:text-slate-200"
        >
          <icon name="ph:hash-straight-bold" />
        </span>
      </span>
      <slot />
    </a>
    <slot v-else />
  </h2>
</template>

<script setup lang="ts">
  const props = defineProps<{ id?: string }>()

  const { headings } = useRuntimeConfig().public.mdc
  const generate = computed(() => props.id && ((typeof headings?.anchorLinks === 'boolean' && headings?.anchorLinks === true) || (typeof headings?.anchorLinks === 'object' && headings?.anchorLinks?.h2)))
</script>

ProseImg Component

We’ll customize the image component to include captions and optionally allow the image to take up the full screen.

<template>
  <nuxt-picture
    :src="refinedSrc"
    :alt="alt"
    :width="width"
    :height="height"
    format="avif,webp"
    :img-attrs="{ class: 'rounded-md', loading: 'lazy' }"
    sizes="sm:100vw 2xl:1536px"
    density="1x 2x"
    v-bind="$attrs"
  />
  <em class="not-italic text-xs text-center block font-normal my-4">{{ alt }}</em>
</template>

<script setup lang="ts">
import { joinURL, withLeadingSlash, withTrailingSlash } from 'ufo'

defineOptions({ inheritAttrs: false })

const props = defineProps({
  src: {
    type: String,
    default: '',
  },
  alt: {
    type: String,
    default: '',
  },
  width: {
    type: [String, Number],
    default: undefined,
  },
  height: {
    type: [String, Number],
    default: undefined,
  },
})

const refinedSrc = computed(() => {
  if (props.src?.startsWith('/') && !props.src.startsWith('//')) {
    const _base = withLeadingSlash(withTrailingSlash(useRuntimeConfig().app.baseURL))
    if (_base !== '/' && !props.src.startsWith(_base)) {
      return joinURL(_base, props.src)
    }
  }
  return props.src
})
</script>

<style scoped>
picture {
  @apply my-0;
}
.wide :deep(img) {
  @apply w-[calc(100vw-15px)] ml-[50%] -translate-x-1/2 max-w-none relative z-10 rounded-none;
}
</style>
  • Lines 2–12: Use the Nuxt Image module to render images.
  • Line 13: Use the alt attribute value as the caption.
  • Lines 55–57: Defines a CSS class that makes the image take up the entire screen.

We will create a gallery component, which is particularly useful for scenarios where multiple photos, such as travel pictures, need to be added at once.

<script setup lang="ts">
  defineProps<{ caption: string }>()
</script>

<template>
  <div class="my-[1.7777778em]">
    <div class="grid grid-cols-3 justify-center items-center gap-3 md:gap-5 *:m-0">
      <content-slot unwrap="p" />
    </div>
    <em class="not-italic text-xs text-center block font-normal my-4">
      {{ caption }}
    </em>
  </div>
</template>

<style scoped>
  :deep(picture) {
    @apply h-full w-full;
  }

  :deep(picture~em) {
    @apply hidden;
  }
</style>

Sample Document

I'd like to share a sample document that serves as a style guide.

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
│   └── content
│       ├── Gallery.vue
│       ├── ProseH2.vue
│       ├── ProseH3.vue
│       ├── ProseH4.vue
│       └── ProseImg.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
│   │   ├── [...slug].vue
│   │   └── index.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