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 takesParsedContent
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.
Gallery Component
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