BIO.RE
Content

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

ParamTypeNotes
slugstringBlog 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

HeaderValue
Cache-Controlpublic, 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" }
    ]
  }
}
FieldTypeNotes
slugstringEchoes the path param
titlestringPost title
excerptstring | nullShort summary (also shown in the list endpoint)
contentstringServer-side sanitized HTML body
featuredImagestring | nullCover image URL
publishedAtstring (ISO 8601) | nullWhen the post was published
categoriesarray{ name, slug } pairs for breadcrumbs / related-posts UI

Stripped fields

id, status, publishedBy, authorId, createdAt, updatedAt โ€” all internal-only, excluded.

Errors

HTTPcode / i18nKeyReason
404error.content.blog_not_foundNo BlogPost matches slug AND status = PUBLISHED
429(throttle)Rate limit exceeded (60 req/min)

Side effects

  1. prisma.blogPost.findFirst({ where: { slug, status: PUBLISHED }, include: { categories: { include: { category: true } } } }).
  2. Missing โ†’ throw blog_not_found (404).
  3. HTML sanitization โ€” sanitizeHtml(post.content) (same helper as CMS pages โ€” script/style/iframe/on* strips).
  4. Map categories from join shape { category: { name, slug } } to flat { name, slug }.
  5. Return assembled object. No mutations.

Code samples

curl https://api.bio.re/api/v1/public/blog/introducing-biore
type 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

GET
/api/v1/public/blog/{slug}

Path Parameters

slug*string

Response Body

application/json

application/json

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

SourcePathLines
Controllerapps/api-core/src/modules/content/public-content.controller.ts239โ€“263 (getBlogPost), 29โ€“36 (sanitizeHtml)
DTO (response)apps/api-core/src/modules/content/dto/content-public-response.dto.ts117โ€“138 (PublicBlogPostDto), 61โ€“67 (PublicBlogCategoryEmbedDto)
Serviceapps/api-core/src/modules/content/content.service.ts547โ€“549 (getBlogPost)
Prisma modelspackages/prisma/prisma/schema.prismaBlogPost (filter status = PUBLISHED), BlogCategory (joined via categories relation)

On this page