Get Blog Post (by slug)
Public read of a single blog post by slug. PUBLISHED-only. HTML body server-side sanitized. Categories embedded as { name, slug } pairs. 404 on missing/draft.
GET /api/v1/public/blog/:slug โ ๐ Public ยท Rate limit: 60 req / minute
Returns a single blog post by slug. PUBLISHED-only โ DRAFT / ARCHIVED slugs return 404. The HTML body is server-side sanitized (same sanitizeHtml() helper as CMS pages โ strips <script>, <style>, <iframe>, inline on* event handlers).
Route order matters. The controller declares /blog/feed and /blog/feed.atom BEFORE /blog/:slug โ without that ordering, NestJS would match feed and feed.atom as slug parameters and 404 the actual feed lookups. From a client perspective this is invisible, but reserved slugs feed and feed.atom will never resolve to a post here.
Sanitization is defensive, not exhaustive. Same caveat as GET /public/pages/:slug โ strips obvious XSS but admin authors are still ultimately trusted. Use client-side sanitization (DOMPurify) for additional defense.
Request
Path parameters
| Param | Type | Notes |
|---|---|---|
slug | string | Blog post slug (e.g. introducing-biore). Reserved values feed and feed.atom are intercepted by the feed routes (see callout). |
No body, no headers required.
Response headers
| Header | Value |
|---|---|
Cache-Control | public, s-maxage=300, stale-while-revalidate=600 |
Response
200 OK โ ApiResponseOf<PublicBlogPostDto>
{
"success": true,
"data": {
"slug": "introducing-biore",
"title": "Introducing BIO.RE",
"excerpt": "A short excerpt of the post...",
"content": "<p>Sanitized HTML content</p>",
"featuredImage": "https://cdn.bio.re/blog/cover.jpg",
"publishedAt": "2026-04-29T20:00:00.000Z",
"categories": [
{ "name": "Product Updates", "slug": "product-updates" }
]
}
}| Field | Type | Notes |
|---|---|---|
slug | string | Echoes the path param |
title | string | Post title |
excerpt | string | null | Short summary (also shown in the list endpoint) |
content | string | Server-side sanitized HTML body |
featuredImage | string | null | Cover image URL |
publishedAt | string (ISO 8601) | null | When the post was published |
categories | array | { name, slug } pairs for breadcrumbs / related-posts UI |
Stripped fields
id, status, publishedBy, authorId, createdAt, updatedAt โ all internal-only, excluded.
Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
404 | error.content.blog_not_found | No BlogPost matches slug AND status = PUBLISHED |
429 | (throttle) | Rate limit exceeded (60 req/min) |
Side effects
prisma.blogPost.findFirst({ where: { slug, status: PUBLISHED }, include: { categories: { include: { category: true } } } }).- Missing โ throw
blog_not_found(404). - HTML sanitization โ
sanitizeHtml(post.content)(same helper as CMS pages โ script/style/iframe/on* strips). - Map categories from join shape
{ category: { name, slug } }to flat{ name, slug }. - Return assembled object. No mutations.
Code samples
curl https://api.bio.re/api/v1/public/blog/introducing-bioretype BlogPost = {
slug: string;
title: string;
excerpt: string | null;
content: string;
featuredImage: string | null;
publishedAt: string | null;
categories: { name: string; slug: string }[];
};
async function getBlogPost(slug: string): Promise<BlogPost> {
const res = await fetch(`https://api.bio.re/api/v1/public/blog/${encodeURIComponent(slug)}`);
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Blog post fetch failed'), {
code: json?.error?.code,
});
}
return json.data;
}import { useQuery } from '@tanstack/react-query';
export const contentKeys = {
blogPost: (slug: string) => ['content', 'blog', 'post', slug] as const,
};
export function useBlogPost(slug: string) {
return useQuery({
queryKey: contentKeys.blogPost(slug),
queryFn: async () => {
const res = await fetch(`/api/v1/public/blog/${encodeURIComponent(slug)}`);
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Blog post fetch failed'), {
code: json?.error?.code,
i18nKey: json?.error?.i18nKey,
});
}
return json.data as BlogPost;
},
enabled: Boolean(slug),
staleTime: 5 * 60_000,
});
}Try it
curl -X GET "https://loading/api/v1/public/blog/string"{
"success": true,
"data": {
"slug": "introducing-biore",
"title": "Introducing BIO.RE",
"excerpt": "string",
"content": "<p>Sanitized HTML content</p>",
"featuredImage": "https://cdn.bio.re/blog/cover.jpg",
"publishedAt": "2019-08-24T14:15:22Z",
"categories": [
{
"slug": "product-updates",
"name": "Product Updates"
}
]
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}Source
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/content/public-content.controller.ts | 239โ263 (getBlogPost), 29โ36 (sanitizeHtml) |
| DTO (response) | apps/api-core/src/modules/content/dto/content-public-response.dto.ts | 117โ138 (PublicBlogPostDto), 61โ67 (PublicBlogCategoryEmbedDto) |
| Service | apps/api-core/src/modules/content/content.service.ts | 547โ549 (getBlogPost) |
| Prisma models | packages/prisma/prisma/schema.prisma | BlogPost (filter status = PUBLISHED), BlogCategory (joined via categories relation) |
Blog Atom Feed
Atom XML feed of the last 20 published blog posts. Same data as the RSS feed in Atom format. Content-Type application/atom+xml. CDN 15min/30min SWR.
List Help Categories
Public list of published help center categories with article counts. Optional locale filter. Ordered by admin-set sortOrder. CDN 5min/10min SWR.