33 KiB
Blog System Design Document
Overview
This document outlines the technical design for implementing a fully-featured blog system in the Nuxt 4 portfolio application using Nuxt Content v3. The design integrates seamlessly with the existing architecture, leveraging Nuxt UI components, i18n for bilingual support, and modern performance optimization techniques.
Design Goals
- Content-First Architecture: Use file-based markdown content management for simplicity and version control
- Seamless Integration: Maintain consistency with existing portfolio design system and navigation
- Bilingual Support: Full English and Persian (RTL) support with locale-specific content
- Performance: Optimize for fast page loads, SEO, and Core Web Vitals
- Developer Experience: Hot-reload, TypeScript safety, and intuitive content authoring
- Extensibility: Support for MDC components, custom frontmatter, and future enhancements
Technology Stack
- Content Management: @nuxt/content v3.x
- UI Components: Nuxt UI v4 (existing)
- Styling: Tailwind CSS v4 (existing)
- Internationalization: @nuxtjs/i18n (existing)
- Image Optimization: @nuxt/image (existing)
- Syntax Highlighting: Shiki (built-in with Nuxt Content)
- Type Safety: TypeScript with custom interfaces
Architecture
High-Level Architecture Diagram
┌─────────────────────────────────────────────────────────────┐
│ Nuxt 4 Application │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Pages │ │ Components │ │
│ │ │ │ │ │
│ │ /blog/ │─────▶│ BlogList │ │
│ │ index.vue │ │ BlogCard │ │
│ │ │ │ BlogSearch │ │
│ │ /blog/ │ │ TagFilter │ │
│ │ [...slug] │─────▶│ BlogPost │ │
│ │ .vue │ │ TableOfContents │
│ └──────────────┘ │ BlogNav │ │
│ │ └──────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ Nuxt Content Module │ │
│ │ - queryContent() API │ │
│ │ - ContentDoc component │ │
│ │ - ContentRenderer component │ │
│ │ - useContentHead() composable │ │
│ └─────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ Content Directory │ │
│ │ content/ │ │
│ │ ├── en/blog/*.md │ │
│ │ └── fa/blog/*.md │ │
│ └─────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Directory Structure
project-root/
├── content/ # Content directory (new)
│ ├── en/
│ │ └── blog/
│ │ ├── my-first-post.md
│ │ ├── nuxt-tips.md
│ │ └── tutorials/
│ │ └── getting-started.md
│ └── fa/
│ └── blog/
│ ├── first-post-fa.md
│ └── nuxt-tips-fa.md
│
├── app/
│ ├── pages/
│ │ └── blog/
│ │ ├── index.vue # Blog listing page (existing, to be updated)
│ │ └── [...slug].vue # Blog detail page (existing, to be updated)
│ │
│ ├── components/
│ │ └── blog/ # Blog components (new)
│ │ ├── BlogCard.vue
│ │ ├── BlogList.vue
│ │ ├── BlogSearch.vue
│ │ ├── BlogTagFilter.vue
│ │ ├── BlogPost.vue
│ │ ├── BlogTableOfContents.vue
│ │ ├── BlogNavigation.vue
│ │ └── BlogEmpty.vue
│ │
│ ├── composables/
│ │ └── useBlog.ts # Blog utilities composable (new)
│ │
│ ├── types/
│ │ └── blog.ts # Blog TypeScript types (new)
│ │
│ └── public/
│ └── img/
│ └── blog/ # Blog images (new)
│ └── default-cover.jpg
│
├── server/
│ └── routes/
│ └── blog/
│ └── rss.xml.ts # RSS feed generator (new)
│
└── nuxt.config.ts # Updated with @nuxt/content
Content Flow
- Content Creation: Developer writes markdown files in
content/{locale}/blog/ - Content Parsing: Nuxt Content parses markdown and frontmatter on server start
- Content Query: Pages use
queryContent()to fetch filtered/sorted content - Content Rendering:
ContentRenderertransforms markdown to HTML with Vue components - Content Display: Nuxt UI components style the rendered content
Components and Interfaces
Page Components
1. Blog Listing Page (app/pages/blog/index.vue)
Purpose: Display all published blog posts with search and filtering capabilities
Key Features:
- Fetch posts using
queryContent() - Search functionality with debounce
- Tag filtering
- Responsive grid layout
- Empty state handling
- Locale-aware content fetching
Component Structure:
<template>
<UContainer>
<BlogSearch v-model="searchQuery" />
<BlogTagFilter v-model="selectedTag" :tags="allTags" />
<BlogList :posts="filteredPosts" />
<BlogEmpty v-if="filteredPosts.length === 0" />
</UContainer>
</template>
Data Fetching Strategy:
const { locale } = useI18n()
const route = useRoute()
// Fetch posts for current locale
const { data: posts } = await useAsyncData('blog-posts', () =>
queryContent(`${locale.value}/blog`)
.where({ draft: { $ne: true } })
.sort({ date: -1 })
.only(['title', 'description', 'date', 'tags', '_path', 'image'])
.find()
)
2. Blog Detail Page (app/pages/blog/[...slug].vue)
Purpose: Render individual blog post with full content and metadata
Key Features:
- Fetch single post by slug
- Render markdown content with syntax highlighting
- Display metadata (title, date, tags, reading time)
- Table of contents for long posts
- Previous/next post navigation
- SEO meta tags
- Breadcrumb navigation
Component Structure:
<template>
<UContainer>
<UBreadcrumb :links="breadcrumbs" />
<article>
<BlogPost :post="post" />
<ContentRenderer :value="post" />
<BlogTableOfContents v-if="post.body?.toc" :toc="post.body.toc" />
</article>
<BlogNavigation :prev="prevPost" :next="nextPost" />
</UContainer>
</template>
Data Fetching Strategy:
const { locale } = useI18n()
const route = useRoute()
const slug = route.params.slug as string[]
// Fetch current post
const { data: post } = await useAsyncData(`blog-post-${slug.join('/')}`, () =>
queryContent(`${locale.value}/blog`)
.where({ _path: `/${locale.value}/blog/${slug.join('/')}` })
.findOne()
)
if (!post.value) {
throw createError({ statusCode: 404, message: 'Post not found' })
}
// Fetch adjacent posts for navigation
const { data: adjacentPosts } = await useAsyncData('adjacent-posts', () =>
queryContent(`${locale.value}/blog`)
.where({ draft: { $ne: true } })
.sort({ date: -1 })
.only(['title', '_path', 'date'])
.find()
)
UI Components
1. BlogCard Component
Purpose: Display blog post preview in listing page
Props:
interface BlogCardProps {
post: BlogPost
}
Features:
- Cover image with lazy loading
- Title and description
- Formatted date
- Reading time estimate
- Tags as badges
- Hover effects
- Click to navigate
Implementation Notes:
- Use
UCardfrom Nuxt UI as base - Use
NuxtImgfor optimized images - Use
UBadgefor tags - Use
localePath()for navigation
2. BlogSearch Component
Purpose: Search input for filtering posts
Props:
interface BlogSearchProps {
modelValue: string
}
interface BlogSearchEmits {
'update:modelValue': [value: string]
}
Features:
- Debounced input (300ms)
- Clear button
- Search icon
- Placeholder text (i18n)
Implementation Notes:
- Use
UInputwith icon slots - Use
useDebounceFnfrom VueUse - Emit updates to parent
3. BlogTagFilter Component
Purpose: Display and filter by tags
Props:
interface BlogTagFilterProps {
tags: string[]
modelValue: string | null
}
Features:
- Display all unique tags
- Highlight active tag
- Clear filter option
- Responsive layout
Implementation Notes:
- Use
UButtonorUBadgefor tags - Use query parameters for state persistence
- Horizontal scroll on mobile
4. BlogTableOfContents Component
Purpose: Display navigable table of contents for long posts
Props:
interface TocLink {
id: string
text: string
depth: number
children?: TocLink[]
}
interface BlogTableOfContentsProps {
toc: {
links: TocLink[]
}
}
Features:
- Nested heading structure
- Active section highlighting
- Smooth scroll to sections
- Sticky positioning on desktop
- Collapsible on mobile
Implementation Notes:
- Use
IntersectionObserverfor active tracking - Use
scrollIntoView({ behavior: 'smooth' })for navigation - Use
UAccordionfor mobile collapsible version
5. BlogNavigation Component
Purpose: Previous/next post navigation
Props:
interface BlogNavigationProps {
prev: BlogPost | null
next: BlogPost | null
}
Features:
- Previous and next post links
- Post titles
- Directional arrows
- Keyboard navigation support
Implementation Notes:
- Use
UButtonwith icon slots - Use
@keydownfor arrow key navigation - Use flexbox for layout
6. BlogPost Component
Purpose: Display post metadata header
Props:
interface BlogPostProps {
post: BlogPost
}
Features:
- Post title (h1)
- Formatted publish date
- Reading time estimate
- Author info (if available)
- Tags
- Cover image
Implementation Notes:
- Use semantic HTML (
<article>,<header>,<time>) - Use
useDateFormatfrom VueUse for date formatting - Calculate reading time from word count
Data Models
BlogPost Interface
import type { ParsedContent } from '@nuxt/content/dist/runtime/types'
export interface BlogPost extends ParsedContent {
// Required fields
title: string
description: string
date: string // ISO 8601 format
tags: string[]
// Optional fields
image?: string // Cover image path
author?: string
draft?: boolean
updatedAt?: string
// Custom SEO
head?: {
title?: string
description?: string
image?: string
}
// Computed by Nuxt Content
_path: string
_dir: string
_draft: boolean
_partial: boolean
_locale: string
_empty: boolean
body: {
type: string
children: any[]
toc?: {
title: string
searchDepth: number
depth: number
links: TocLink[]
}
}
}
Frontmatter Example
---
title: "Getting Started with Nuxt Content"
description: "Learn how to build a blog with Nuxt Content v3 and TypeScript"
date: "2024-11-09"
tags: ["nuxt", "vue", "typescript", "tutorial"]
image: "/img/blog/nuxt-content-cover.jpg"
author: "Ali Arghyani"
draft: false
---
Composables
useBlog Composable
Purpose: Centralize blog-related utilities and logic
Location: app/composables/useBlog.ts
Exports:
export function useBlog() {
const { locale } = useI18n()
const route = useRoute()
/**
* Calculate reading time from word count
*/
const calculateReadingTime = (content: any): number => {
if (!content?.body?.children) return 0
const text = JSON.stringify(content.body.children)
const wordCount = text.split(/\s+/).length
return Math.ceil(wordCount / 200) // 200 words per minute
}
/**
* Format date for display
*/
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return new Intl.DateTimeFormat(locale.value, {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(date)
}
/**
* Get all unique tags from posts
*/
const extractUniqueTags = (posts: BlogPost[]): string[] => {
const tagSet = new Set<string>()
posts.forEach(post => {
post.tags?.forEach(tag => tagSet.add(tag))
})
return Array.from(tagSet).sort()
}
/**
* Get blog path for current locale
*/
const getBlogPath = (): string => {
return `${locale.value}/blog`
}
/**
* Filter posts by search query
*/
const filterPostsBySearch = (posts: BlogPost[], query: string): BlogPost[] => {
if (!query) return posts
const lowerQuery = query.toLowerCase()
return posts.filter(post =>
post.title?.toLowerCase().includes(lowerQuery) ||
post.description?.toLowerCase().includes(lowerQuery) ||
post.tags?.some(tag => tag.toLowerCase().includes(lowerQuery))
)
}
/**
* Filter posts by tag
*/
const filterPostsByTag = (posts: BlogPost[], tag: string | null): BlogPost[] => {
if (!tag) return posts
return posts.filter(post => post.tags?.includes(tag))
}
return {
calculateReadingTime,
formatDate,
extractUniqueTags,
getBlogPath,
filterPostsBySearch,
filterPostsByTag
}
}
Nuxt Content Configuration
nuxt.config.ts Updates
export default defineNuxtConfig({
modules: [
'@nuxt/content', // Add before other modules
'@nuxt/fonts',
'@nuxt/ui',
'@nuxtjs/i18n',
'@nuxtjs/color-mode',
'@nuxt/image'
],
content: {
// Highlight code blocks with Shiki
highlight: {
theme: {
default: 'github-light',
dark: 'github-dark'
},
preload: ['typescript', 'javascript', 'vue', 'css', 'bash', 'json']
},
// Enable MDC syntax
markdown: {
mdc: true,
toc: {
depth: 3,
searchDepth: 3
}
},
// Document-driven mode disabled (we use custom pages)
documentDriven: false,
// Respect gitignore
respectPathCase: true
},
// Prerender blog routes
nitro: {
prerender: {
crawlLinks: true,
routes: ['/blog', '/fa/blog']
}
},
// Route rules for blog
routeRules: {
'/blog': { swr: 3600 },
'/fa/blog': { swr: 3600 },
'/blog/**': { swr: 3600 },
'/fa/blog/**': { swr: 3600 }
}
})
Styling and Theming
Prose Components Customization
Nuxt UI provides Prose components for styling markdown content. We'll customize them to match the portfolio design.
Approach:
- Use Nuxt UI's built-in Prose components (ProseH1, ProseP, ProseCode, etc.)
- Override styles via
app.config.tsif needed - Apply consistent spacing and typography
Example Prose Styling:
// app.config.ts additions
export default defineAppConfig({
ui: {
prose: {
// Customize prose components
h1: {
base: 'text-4xl font-semibold mb-4 mt-8',
color: 'text-gray-900 dark:text-gray-100'
},
h2: {
base: 'text-3xl font-semibold mb-3 mt-6',
color: 'text-gray-900 dark:text-gray-100'
},
p: {
base: 'text-lg leading-relaxed mb-4',
color: 'text-gray-700 dark:text-gray-300'
},
code: {
base: 'font-mono text-sm',
inline: 'px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800'
}
}
}
})
Blog-Specific Styles
Content Width:
- Max width: 65ch for optimal readability
- Wider for code blocks and images
Typography:
- Body text: 18px / 1.75 line-height
- Headings: Existing font stack (Fraunces for headings, Inter for body)
Code Blocks:
- Syntax highlighting with Shiki
- Copy button overlay
- Language label
- Line numbers for long blocks
Images:
- Responsive with
NuxtImg - Lazy loading
- Caption support via markdown
RTL Support
CSS Direction Handling:
<template>
<div :dir="locale === 'fa' ? 'rtl' : 'ltr'" class="blog-content">
<ContentRenderer :value="post" />
</div>
</template>
<style scoped>
.blog-content[dir="rtl"] {
/* RTL-specific adjustments */
text-align: right;
}
.blog-content[dir="rtl"] code {
/* Keep code LTR even in RTL context */
direction: ltr;
text-align: left;
}
</style>
SEO Implementation
Meta Tags Strategy
Page-Level SEO (app/pages/blog/[...slug].vue):
const { locale } = useI18n()
const config = useAppConfig()
// Use Nuxt Content's built-in SEO helper
useContentHead(post)
// Additional custom meta tags
useSeoMeta({
title: `${post.value.title} | Blog | ${config.siteName}`,
description: post.value.description,
ogTitle: post.value.title,
ogDescription: post.value.description,
ogImage: post.value.image || '/img/blog/default-cover.jpg',
ogType: 'article',
ogUrl: `${config.siteUrl}${post.value._path}`,
twitterCard: 'summary_large_image',
twitterTitle: post.value.title,
twitterDescription: post.value.description,
twitterImage: post.value.image || '/img/blog/default-cover.jpg',
articlePublishedTime: post.value.date,
articleModifiedTime: post.value.updatedAt || post.value.date,
articleAuthor: post.value.author || 'Ali Arghyani',
articleTag: post.value.tags
})
// JSON-LD structured data
useHead({
script: [
{
type: 'application/ld+json',
children: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.value.title,
description: post.value.description,
image: post.value.image,
datePublished: post.value.date,
dateModified: post.value.updatedAt || post.value.date,
author: {
'@type': 'Person',
name: post.value.author || 'Ali Arghyani'
},
publisher: {
'@type': 'Person',
name: 'Ali Arghyani'
}
})
}
]
})
RSS Feed Implementation
Server Route Design
Location: server/routes/blog/rss.xml.ts
Implementation:
import { serverQueryContent } from '#content/server'
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const locale = event.node.req.url?.includes('/fa/') ? 'fa' : 'en'
// Fetch published posts
const posts = await serverQueryContent(event, `${locale}/blog`)
.where({ draft: { $ne: true } })
.sort({ date: -1 })
.limit(20)
.find()
// Generate RSS XML
const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Ali Arghyani - Blog</title>
<link>${config.public.siteUrl}/${locale}/blog</link>
<description>Stories and notes from Ali Arghyani</description>
<language>${locale}</language>
<atom:link href="${config.public.siteUrl}/${locale}/blog/rss.xml" rel="self" type="application/rss+xml"/>
${posts.map(post => `
<item>
<title>${escapeXml(post.title)}</title>
<link>${config.public.siteUrl}${post._path}</link>
<guid>${config.public.siteUrl}${post._path}</guid>
<pubDate>${new Date(post.date).toUTCString()}</pubDate>
<description>${escapeXml(post.description)}</description>
</item>
`).join('')}
</channel>
</rss>`
event.node.res.setHeader('Content-Type', 'application/rss+xml')
return rss
})
function escapeXml(unsafe: string): string {
return unsafe.replace(/[<>&'"]/g, (c) => {
switch (c) {
case '<': return '<'
case '>': return '>'
case '&': return '&'
case "'": return '''
case '"': return '"'
default: return c
}
})
}
Error Handling
404 Handling
When a blog post is not found:
// In [...slug].vue
const { data: post } = await useAsyncData(`blog-post-${slug}`, () =>
queryContent(getBlogPath())
.where({ _path: `/${getBlogPath()}/${slug.join('/')}` })
.findOne()
)
if (!post.value) {
throw createError({
statusCode: 404,
message: 'Blog post not found',
fatal: true
})
}
This will trigger the existing app/error.vue page.
Content Parsing Errors
Nuxt Content will show errors in development mode with:
- File path
- Line number
- Error description
In production, malformed markdown files will be skipped.
Missing Translations
When a post exists in one language but not another:
// In blog listing
const { data: posts } = await useAsyncData('blog-posts', async () => {
try {
return await queryContent(getBlogPath())
.where({ draft: { $ne: true } })
.sort({ date: -1 })
.find()
} catch (error) {
// Return empty array if content directory doesn't exist
return []
}
})
Display a message: "This post is not available in [language]. View in [other language]."
Testing Strategy
Manual Testing Checklist
Content Creation:
- Create markdown file with valid frontmatter
- Create markdown file with missing required fields
- Create draft post
- Create post with cover image
- Create post with code blocks
- Create post with MDC components
Blog Listing:
- View listing page in English
- View listing page in Persian
- Test search functionality
- Test tag filtering
- Test empty state (no posts)
- Test responsive layout (mobile, tablet, desktop)
Blog Detail:
- View post in English
- View post in Persian (RTL)
- Test table of contents navigation
- Test previous/next navigation
- Test code block copy functionality
- Test 404 for non-existent post
- Verify SEO meta tags in browser dev tools
Performance:
- Run Lighthouse audit
- Check image lazy loading
- Verify static generation
- Test hot-reload in development
Accessibility:
- Keyboard navigation
- Screen reader compatibility
- Color contrast
- Focus indicators
Performance Optimization
Image Optimization
Strategy:
- Use
NuxtImgfor all blog images - Generate responsive srcsets
- Lazy load images below fold
- Use modern formats (WebP, AVIF)
Implementation:
<!-- In markdown or components -->
<NuxtImg
:src="post.image"
:alt="post.title"
width="1200"
height="630"
format="webp"
loading="lazy"
sizes="sm:100vw md:80vw lg:1200px"
/>
Code Splitting
Approach:
- Blog components are auto-imported (lazy by default)
- Use
defineAsyncComponentfor heavy components like TOC - Separate chunks for blog routes
Example:
// In blog/index.vue
const BlogTableOfContents = defineAsyncComponent(
() => import('~/components/blog/BlogTableOfContents.vue')
)
Content Query Optimization
Best Practices:
- Use
.only()to fetch minimal fields for listings - Use
.without()to exclude heavy fields like body - Cache queries with
useAsyncData - Limit results with
.limit()
Example:
// Listing page - only fetch necessary fields
const { data: posts } = await useAsyncData('blog-posts', () =>
queryContent(getBlogPath())
.where({ draft: { $ne: true } })
.sort({ date: -1 })
.only(['title', 'description', 'date', 'tags', '_path', 'image'])
.find()
)
// Detail page - fetch full content
const { data: post } = await useAsyncData(`blog-post-${slug}`, () =>
queryContent(getBlogPath())
.where({ _path: `/${getBlogPath()}/${slug}` })
.findOne()
)
Static Generation
Configuration:
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
prerender: {
crawlLinks: true,
routes: async () => {
// Dynamically generate routes for all blog posts
const { $content } = useNuxtApp()
const enPosts = await $content('en/blog').only(['_path']).find()
const faPosts = await $content('fa/blog').only(['_path']).find()
return [
'/blog',
'/fa/blog',
...enPosts.map(p => p._path),
...faPosts.map(p => p._path)
]
}
}
}
})
Caching Strategy
Route Rules:
- Blog listing: SWR 1 hour
- Blog posts: SWR 1 hour
- RSS feed: SWR 1 hour
routeRules: {
'/blog': { swr: 3600 },
'/fa/blog': { swr: 3600 },
'/blog/**': { swr: 3600 },
'/fa/blog/**': { swr: 3600 },
'/blog/rss.xml': { swr: 3600 },
'/fa/blog/rss.xml': { swr: 3600 }
}
Internationalization Integration
Locale-Based Content Fetching
Pattern:
const { locale } = useI18n()
const { localePath } = useI18n()
// Fetch content for current locale
const posts = await queryContent(`${locale.value}/blog`).find()
// Generate localized links
const postLink = localePath(`/blog/${post.slug}`)
Translation Keys
New i18n keys to add:
{
"blog": {
"title": "Blog",
"explore": "Stories & notes",
"empty": "No posts yet. Check back soon!",
"readMore": "Read article",
"readingTime": "{minutes} min read",
"publishedOn": "Published on",
"updatedOn": "Updated on",
"backToBlog": "Back to Blog",
"previousPost": "Previous",
"nextPost": "Next",
"tableOfContents": "Table of Contents",
"searchPlaceholder": "Search posts...",
"filterByTag": "Filter by tag",
"allPosts": "All posts",
"noResults": "No posts found matching your search",
"copyCode": "Copy code",
"codeCopied": "Copied!",
"subscribe": "Subscribe via RSS"
}
}
RTL Layout Adjustments
CSS Strategy:
/* Automatic RTL with Tailwind */
.blog-content {
@apply text-start; /* Uses start instead of left */
}
/* Force LTR for code */
.blog-content pre,
.blog-content code {
direction: ltr;
text-align: left;
}
/* RTL-aware spacing */
.blog-content ul,
.blog-content ol {
@apply ps-6; /* padding-inline-start */
}
Migration from Existing Blog Pages
Current State
The existing blog pages (app/pages/blog/index.vue and app/pages/blog/[...slug].vue) have placeholder content indicating Nuxt Content is disabled.
Migration Steps
- Backup existing files (if needed)
- Replace page content with new implementations
- Add i18n translations for blog-specific keys
- Create sample blog posts for testing
- Update navigation to enable blog link in TopNav
Backward Compatibility
No breaking changes expected since:
- Routes remain the same (
/blog,/fa/blog) - i18n structure is preserved
- Existing components are not affected
MDC Component Examples
Custom Components in Markdown
Nuxt Content's MDC syntax allows embedding Vue components in markdown.
Example 1: Alert Component
::alert{type="info"}
This is an informational alert using MDC syntax.
::
Example 2: Code Group
::code-group
```bash [npm]
npm install @nuxt/content
\```
```bash [pnpm]
pnpm add @nuxt/content
\```
::
Example 3: Custom Blog Component
::blog-callout{title="Pro Tip"}
Use `queryContent()` with `.only()` to optimize performance.
::
Component Implementation (app/components/content/BlogCallout.vue):
<template>
<UCard class="my-6 border-l-4 border-primary-500">
<template #header>
<h4 class="font-semibold">{{ title }}</h4>
</template>
<div class="prose prose-sm">
<slot />
</div>
</UCard>
</template>
<script setup lang="ts">
defineProps<{
title: string
}>()
</script>
Development Workflow
Content Authoring Workflow
- Create markdown file in
content/{locale}/blog/ - Add frontmatter with required fields
- Write content using markdown and MDC syntax
- Preview in dev mode (drafts visible)
- Test in both locales if bilingual
- Set draft: false when ready to publish
- Commit to git (content is version controlled)
Hot Reload Experience
Nuxt Content provides instant feedback:
- Markdown changes: Hot-reload without page refresh
- Frontmatter changes: Hot-reload
- New files: Detected automatically
- Component changes: Standard Nuxt HMR
Debugging Tools
1. Content API Endpoint:
http://localhost:3000/api/_content/query
2. Dev Tools:
- Nuxt DevTools shows content queries
- Vue DevTools for component inspection
3. Console Logging:
const posts = await queryContent('en/blog').find()
console.log('Fetched posts:', posts)
Security Considerations
Content Sanitization
Nuxt Content automatically sanitizes HTML in markdown to prevent XSS attacks.
Safe by default:
- User-provided markdown is parsed safely
- HTML tags are escaped unless explicitly allowed
- Script tags are stripped
Draft Post Protection
// Ensure drafts are hidden in production
const isDev = process.dev
const query = queryContent(getBlogPath())
.where({ draft: { $ne: true } })
if (isDev) {
// In dev, show all posts including drafts
query.where({}) // Remove draft filter
}
Image Security
- Use
@nuxt/imagefor automatic optimization - Validate image paths
- Use CDN for external images
Deployment Considerations
Build Process
- Install dependencies:
pnpm install - Build application:
pnpm build - Content parsing: Nuxt Content parses all markdown during build
- Static generation: Blog routes are prerendered
- Deploy: Upload
.outputdirectory
Environment Variables
# .env
NUXT_PUBLIC_SITE_URL=https://aliarghyani.vercel.app
NUXT_PUBLIC_SITE_NAME=Ali Arghyani
Vercel Configuration
{
"buildCommand": "pnpm build",
"outputDirectory": ".output/public",
"framework": "nuxtjs"
}
Content Updates
Option 1: Git-based workflow
- Commit markdown files to repository
- Trigger rebuild on push
- Vercel auto-deploys
Option 2: CMS integration (future enhancement)
- Use Nuxt Studio or other headless CMS
- Webhook triggers rebuild
- Content preview before publish
Future Enhancements
Phase 2 Features (Not in current scope)
-
Comments System
- Integration with Giscus or Utterances
- GitHub-based comments
-
Related Posts
- Algorithm based on tags
- Display at end of post
-
Reading Progress Bar
- Scroll-based progress indicator
- Sticky header with progress
-
Social Sharing
- Share buttons for Twitter, LinkedIn
- Copy link functionality
-
Newsletter Subscription
- Email capture form
- Integration with email service
-
Blog Analytics
- View count tracking
- Popular posts widget
-
Content Series
- Multi-part article support
- Series navigation
-
Dark Mode Code Themes
- Multiple syntax highlighting themes
- User preference storage
Summary
This design provides a comprehensive, production-ready blog system that:
- ✅ Integrates seamlessly with existing Nuxt 4 portfolio
- ✅ Supports bilingual content (EN/FA) with RTL
- ✅ Uses Nuxt Content v3 best practices
- ✅ Optimizes for performance and SEO
- ✅ Maintains type safety with TypeScript
- ✅ Provides excellent developer experience
- ✅ Follows accessibility standards
- ✅ Scales for future enhancements
The implementation will be done incrementally following the task list in the next phase.