Add Nuxt frontend with blog index, article and category pages

The browser never talks to Strapi: pages call Nitro endpoints under
server/api/, which are the only place Strapi queries are built. That keeps the
Strapi URL internal to the Docker network and avoids CORS entirely.

Article bodies are rendered from Markdown to HTML in the endpoint, so the
content is server-rendered and crawlable, marked stays out of the client
bundle, and the meta description is derived from the opening of the body.

Pages carry canonical URLs, Open Graph tags and BlogPosting structured data,
and handle empty, missing and failed states.
This commit is contained in:
2026-08-25 11:42:17 +02:00
parent ec5976135f
commit a808f1010f
28 changed files with 10387 additions and 0 deletions
@@ -0,0 +1,24 @@
import type { Article } from '#shared/types/blog'
type RawArticle = Omit<Article, 'html' | 'summary'> & { content: string | null }
export default defineEventHandler(async (event): Promise<Article> => {
const slug = getRouterParam(event, 'slug')
if (!slug) {
throw createError({ statusCode: 400, statusMessage: 'Missing article slug' })
}
const response = await strapiFetch<StrapiList<RawArticle>>(
`/api/articles?${ARTICLE_DETAIL_QUERY}&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1`
)
const article = response.data[0]
if (!article) {
throw createError({ statusCode: 404, statusMessage: 'Article not found' })
}
const { content, ...rest } = article
return { ...rest, html: renderMarkdown(content), summary: summarise(content) }
})
+22
View File
@@ -0,0 +1,22 @@
import type { ArticleSummary, Paginated } from '#shared/types/blog'
export default defineEventHandler(async (event): Promise<Paginated<ArticleSummary>> => {
const query = getQuery(event)
const page = pageParam(query.page)
const category = typeof query.category === 'string' ? query.category : null
const filter = category
? `&filters[category][slug][$eq]=${encodeURIComponent(category)}`
: ''
const response = await strapiFetch<StrapiList<ArticleSummary>>(
`/api/articles?${ARTICLE_SUMMARY_QUERY}&${pagination(page)}${filter}`
)
return {
items: response.data,
page: response.meta.pagination.page,
pageCount: response.meta.pagination.pageCount,
total: response.meta.pagination.total,
}
})
@@ -0,0 +1,21 @@
import type { Category } from '#shared/types/blog'
export default defineEventHandler(async (event): Promise<Category> => {
const slug = getRouterParam(event, 'slug')
if (!slug) {
throw createError({ statusCode: 400, statusMessage: 'Missing category slug' })
}
const response = await strapiFetch<StrapiList<Category>>(
`/api/categories?fields[0]=name&fields[1]=slug&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1`
)
const category = response.data[0]
if (!category) {
throw createError({ statusCode: 404, statusMessage: 'Category not found' })
}
return category
})
@@ -0,0 +1,9 @@
import type { Category } from '#shared/types/blog'
export default defineEventHandler(async (): Promise<Category[]> => {
const response = await strapiFetch<StrapiList<Category>>(
'/api/categories?fields[0]=name&fields[1]=slug&sort[0]=name:asc&pagination[pageSize]=100'
)
return response.data
})