API Reference

Complete reference for the PersonnaPress headless blog delivery API. All endpoints, parameters, error codes, and copy-paste integration examples.

Quickstart

  1. Step 01

    Get your token

    In the app, go to your client, then the Connections tab, then the Delivery API section. Click Create token, give it a name, and copy the ppd_... value. You will only see the full token once.

    Open the app
  2. Step 02

    Call the API

    Send a GET request to https://api.personnapress.com/public/v1/articles with Authorization: Bearer ppd_your_token in the header. The response is a paginated JSON object.

  3. Step 03

    Render the result

    Use article.html for the post body and pass the seo object fields to your page metadata. The seo.json_ld block is ready to embed as-is.

Authentication

All requests require an Authorization header in the format Bearer ppd_<token>. Delivery tokens always start with the prefix ppd_. Do not use session cookies or API keys from the app dashboard.

A missing, revoked, or malformed token returns 401 INVALID_DELIVERY_TOKEN.

Authorization: Bearer ppd_abc123def456ghi789jkl012mno345pqr678stu

The API allows 120 requests per minute per token. Exceeding this returns 429 RATE_LIMIT_EXCEEDED.

List Articles

GET/public/v1/articlesReturns a paginated list of published articles.

Note: the html and seo fields are not included in list responses. Fetch GET /public/v1/articles/{slug} for the full article.

Query Parameters

Query parameters for GET /public/v1/articles
ParameterTypeDefaultDescription
pageoptionalint1Page number for pagination.
page_sizeoptionalint20Items per page. Minimum 1, maximum 50.
tagoptionalstring-Filter articles by tag slug. Returns only articles with this tag.
categoryoptionalstring-Filter articles by category name. Case-insensitive.

Response

{
  "data": [
    {
      "slug": "how-to-price-consulting-services",
      "title": "How to Price Consulting Services Without Guessing",
      "excerpt": "Most consultants underprice because they anchor to hourly rates.",
      "featured_image_url": "https://cdn.personnapress.com/images/how-to-price-consulting.jpg",
      "featured_image_alt": "Chart showing value-based pricing vs hourly rates",
      "author": "Alex Morgan",
      "tags": ["consulting", "pricing"],
      "category": "Business",
      "published_at": "2026-07-14T09:00:00+00:00",
      "updated_at": "2026-07-14T10:23:00+00:00",
      "reading_time_minutes": 7
    }
  ],
  "meta": {
    "page": 1,
    "page_size": 20,
    "total": 42
  }
}

Get Article

GET/public/v1/articles/{slug}Returns a single article with full HTML content and SEO data.

Path parameter: slug (string, required) - the article's URL slug, obtained from the list response.

The meta_description and og fields inside seo are conditional: present only when the article has those fields populated. A 404 is returned for hidden articles, unknown slugs, or articles belonging to another client - all cases are indistinguishable by design.

Response

{
  "slug": "how-to-price-consulting-services",
  "title": "How to Price Consulting Services Without Guessing",
  "excerpt": "Most consultants underprice because they anchor to hourly rates.",
  "featured_image_url": "https://cdn.personnapress.com/images/how-to-price-consulting.jpg",
  "featured_image_alt": "Chart showing value-based pricing vs hourly rates",
  "author": "Alex Morgan",
  "tags": ["consulting", "pricing"],
  "category": "Business",
  "published_at": "2026-07-14T09:00:00+00:00",
  "updated_at": "2026-07-14T10:23:00+00:00",
  "reading_time_minutes": 7,
  "html": "<h2>The problem with hourly pricing</h2><p>When you charge by the hour...</p>",
  "seo": {
    "reading_time_minutes": 7,
    "json_ld": {
      "@context": "https://schema.org",
      "@type": "Article",
      "headline": "How to Price Consulting Services Without Guessing",
      "datePublished": "2026-07-14T09:00:00+00:00",
      "author": { "@type": "Person", "name": "Alex Morgan" }
    },
    "meta_description": "Learn how to price consulting services based on value, not hours.",
    "og": {
      "title": "How to Price Consulting Services Without Guessing",
      "description": "Learn how to price consulting services based on value.",
      "image": "https://cdn.personnapress.com/images/how-to-price-consulting.jpg"
    }
  }
}

List Tags

GET/public/v1/tagsReturns all tags and categories with article counts.

Use this endpoint to build tag clouds, category navigation, or filtered list pages on your site.

Response

{
  "tags": [
    { "name": "consulting", "count": 12 },
    { "name": "pricing", "count": 8 },
    { "name": "freelance", "count": 5 }
  ],
  "categories": [
    { "name": "Business", "count": 18 },
    { "name": "Marketing", "count": 9 }
  ]
}

Create Article

Post a blog article directly to a client with a write-scoped token. The article is stored verbatim after HTML sanitization and lands as hidden in the Article Manager, ready for you to review and publish in the app. No voice, generation, or AI transformation is applied. This endpoint is ideal for a terminal or an AI agent such as Claude.

Authentication uses a Bearer ppw_<token> header. Create a write token in the app under your client's Connections tab and choose the Write scope. A read-only ppd_ token returns 403 WRITE_SCOPE_REQUIRED here. This route is rate limited to 60 requests per minute per token. The request body is capped at 200 KB.

Provide a slug to make the call idempotent: if a hidden article with that slug already exists it is updated and the response is 200 with updated: true. If the slug belongs to a published article the call returns 409 SLUG_CONFLICT_PUBLISHED and never overwrites a live post. Omit the slug and a unique one is derived from the title, always creating a new article ( 201).

POST/public/v1/articlesCreate or upsert a hidden article verbatim.

Request Fields

Fields accepted by POST /public/v1/articles
ParameterTypeDefaultDescription
titlerequiredstring-Article title. Trimmed, 1 to 300 characters.
contentrequiredstring-Article body in the given format. Non-empty.
formatrequired"markdown" | "html"-How to interpret content. Markdown is rendered to HTML; HTML is sanitized directly.
slugoptionalstringautoURL slug (max 200). Omit to derive a unique slug from the title.
excerptoptionalstring-Short summary, max 500 characters.
meta_descriptionoptionalstring-SEO meta description, max 320 characters.
authoroptionalstring-Author name, max 200 characters.
categoryoptionalstring-Category name, max 100 characters.
tagsoptionalstring[]-Up to 20 tags, each max 50 characters.
featured_image_altoptionalstring-Alt text for the featured image, max 300 characters.
featured_image_urloptionalstring-Featured image URL. Must be a valid http(s) URL.

Request Body

{
  "title": "How We Cut Onboarding Time in Half",
  "format": "markdown",
  "content": "## The problem\n\nNew accounts took **three days** to activate.\n\n- Manual review\n- Slow email loops\n",
  "slug": "cut-onboarding-time-in-half",
  "excerpt": "A short summary shown in list views.",
  "meta_description": "How we cut onboarding time in half with two workflow changes.",
  "author": "Alex Morgan",
  "category": "Operations",
  "tags": ["onboarding", "ops"],
  "featured_image_alt": "Before and after onboarding timeline"
}

Response

{
  "id": "0f9c1e7a-4b2d-4a11-9c3e-2a7f8b6d5c40",
  "slug": "cut-onboarding-time-in-half",
  "status": "hidden",
  "edit_url": "https://app.personnapress.com/articles/0f9c1e7a-4b2d-4a11-9c3e-2a7f8b6d5c40",
  "created_at": "2026-09-20T14:02:11+00:00",
  "updated_at": "2026-09-20T14:02:11+00:00",
  "updated": false
}

cURL example

# Create a hidden article from Markdown (Claude / terminal)
curl --silent --fail-with-body \
  -X POST "https://api.personnapress.com/public/v1/articles" \
  -H "Authorization: Bearer ppw_your_write_token_here" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "How We Cut Onboarding Time in Half",
    "format": "markdown",
    "content": "## The problem\n\nNew accounts took three days to activate.\n"
  }'

# The article lands as "hidden" in your Article Manager.
# Open edit_url from the response to review and publish it.

Supported formatting elements

Both markdown and html formats preserve the following elements end-to-end through the pipeline:

  • Headings h1 through h6 (all six levels round-trip intact)
  • Tables — GFM pipe tables in Markdown, or <table> in HTML. Column alignment (left, center, right) is preserved via the align attribute on <th> and <td>.
  • Horizontal rules (Markdown --- or HTML <hr>)
  • Strikethrough (Markdown ~~text~~ or HTML <s> / <del>)

Known limits in v1

  • Inline images whose src is not a PersonnaPress-hosted URL are stripped by the sanitizer. Upload images in the app and reference the hosted URL, or set featured_image_url.
  • Task lists (Markdown - [ ] item) are not supported. The GFM task-list rule is not enabled, so the syntax renders as a plain list item with the literal characters [ ] or [x] as text. No <input> elements are produced or stored.

Read Back Your Articles

After creating an article with a write token, use these endpoints to verify what you created, list all your client's articles at any status, or check whether a slug is already taken before posting. Both endpoints require a Bearer ppw_ write token. A read-only ppd_ token returns 403 WRITE_SCOPE_REQUIRED here. Use the public read routes for published content delivery.

All responses from these endpoints carry Cache-Control: no-store and are never cached. Each item includes api_authored, a boolean that is true when the article was created through this ingestion API and false for articles generated by in-app campaigns.

GET/public/v1/authored/articlesList all articles for the token's client at any status.

Use the slug filter to check existence before posting: if the result is empty the slug is free; if the article is hidden it can be updated; if it is published a POST with that slug returns 409 SLUG_CONFLICT_PUBLISHED.

Query Parameters

Query parameters for GET /public/v1/authored/articles
ParameterTypeDefaultDescription
pageoptionalint1Page number for pagination.
page_sizeoptionalint20Items per page. Minimum 1, maximum 50.
statusoptional"hidden" | "published"-Filter by status. Omit to return articles at every status.
tagoptionalstring-Filter by tag.
categoryoptionalstring-Filter by category name.
slugoptionalstring-Exact slug match (normalized). Returns at most one article. Use this to check whether a slug is taken before posting.

Response

{
  "data": [
    {
      "id": "0f9c1e7a-4b2d-4a11-9c3e-2a7f8b6d5c40",
      "slug": "cut-onboarding-time-in-half",
      "status": "hidden",
      "title": "How We Cut Onboarding Time in Half",
      "excerpt": "A short summary shown in list views.",
      "featured_image_url": null,
      "featured_image_alt": null,
      "author": "Alex Morgan",
      "tags": ["onboarding", "ops"],
      "category": "Operations",
      "published_at": "2026-09-20T14:02:11+00:00",
      "updated_at": "2026-09-20T14:02:11+00:00",
      "reading_time_minutes": 2,
      "edit_url": "https://app.personnapress.com/articles/0f9c1e7a-4b2d-4a11-9c3e-2a7f8b6d5c40",
      "api_authored": true
    }
  ],
  "meta": { "page": 1, "page_size": 20, "total": 1 }
}
GET/public/v1/authored/articles/{id}Return the full article by id at any status.

Path parameter: id (UUID, required) — the article's id from the create response. Returns 404 for unknown ids and for ids that belong to another client — both cases are indistinguishable by design.

Response

{
  "id": "0f9c1e7a-4b2d-4a11-9c3e-2a7f8b6d5c40",
  "slug": "cut-onboarding-time-in-half",
  "status": "hidden",
  "title": "How We Cut Onboarding Time in Half",
  "excerpt": "A short summary shown in list views.",
  "featured_image_url": null,
  "featured_image_alt": null,
  "author": "Alex Morgan",
  "tags": ["onboarding", "ops"],
  "category": "Operations",
  "published_at": "2026-09-20T14:02:11+00:00",
  "updated_at": "2026-09-20T14:02:11+00:00",
  "reading_time_minutes": 2,
  "edit_url": "https://app.personnapress.com/articles/0f9c1e7a-4b2d-4a11-9c3e-2a7f8b6d5c40",
  "api_authored": true,
  "html": "<h2>The problem</h2><p>New accounts took three days to activate.</p>",
  "meta_description": "How we cut onboarding time in half with two workflow changes.",
  "seo": {
    "reading_time_minutes": 2,
    "meta_description": "How we cut onboarding time in half with two workflow changes.",
    "og": {
      "title": "How We Cut Onboarding Time in Half",
      "description": "How we cut onboarding time in half with two workflow changes."
    },
    "json_ld": {
      "@context": "https://schema.org",
      "@type": "Article",
      "headline": "How We Cut Onboarding Time in Half",
      "description": "How we cut onboarding time in half with two workflow changes.",
      "datePublished": "2026-09-20T14:02:11+00:00",
      "author": { "@type": "Person", "name": "Alex Morgan" }
    }
  }
}

Create then read back — cURL example

# Step 1: create an article and capture the id
RESPONSE=$(curl --silent --fail-with-body \
  -X POST "https://api.personnapress.com/public/v1/articles" \
  -H "Authorization: Bearer ppw_your_write_token_here" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "How We Cut Onboarding Time in Half",
    "format": "markdown",
    "content": "## The problem\n\nNew accounts took three days to activate."
  }')

ARTICLE_ID=$(echo "$RESPONSE" | jq -r '.id')

# Step 2: read it back by id
curl --silent --fail-with-body \
  "https://api.personnapress.com/public/v1/authored/articles/$ARTICLE_ID" \
  -H "Authorization: Bearer ppw_your_write_token_here"

Update an Article

Replace an existing article's content in place, addressed by its id (the value returned by the create response or the read-back endpoints). This works for a hidden or a published article. Editing a published article is live immediately and its status stays published. Requires a Bearer ppw_ write token; a read-only ppd_ token returns 403 WRITE_SCOPE_REQUIRED. This route is rate limited to 60 requests per minute per token and the body is capped at 200 KB.

This is a full replace, not a partial patch. Send the article's complete new state: any optional field you omit ( excerpt, tags, category, author, meta_description, featured_image_url, featured_image_alt) is cleared to null, not left unchanged. Read the article back first if you want to preserve current values. Content is stored as sanitized HTML; no voice, generation, or AI transformation is applied.

The slug is immutable through this endpoint. Sending a slug in the body returns 422 VALIDATION_ERROR. An unknown or another client's id returns 404 ARTICLE_NOT_FOUND (both cases are indistinguishable by design). Re-sending identical content is a no-op that still returns 200.

PUT/public/v1/authored/articles/{id}Full-content replace of an existing article in place.

Path parameter: id (UUID, required). A non-UUID id returns 422.

Request Fields

Fields accepted by PUT /public/v1/authored/articles/{id}
ParameterTypeDefaultDescription
titlerequiredstring-Article title. Trimmed, 1 to 300 characters.
contentrequiredstring-Article body in the given format. Non-empty. Replaces the current body.
formatrequired"markdown" | "html"-How to interpret content. Markdown is rendered to HTML; HTML is sanitized directly. The stored value is always sanitized HTML.
excerptoptionalstringclearedShort summary, max 500 characters. Omit to clear the existing value.
meta_descriptionoptionalstringclearedSEO meta description, max 320 characters. Omit to clear.
authoroptionalstringclearedAuthor name, max 200 characters. Omit to clear.
categoryoptionalstringclearedCategory name, max 100 characters. Omit to clear.
tagsoptionalstring[]clearedUp to 20 tags, each max 50 characters. Omit to clear.
featured_image_altoptionalstringclearedAlt text for the featured image, max 300 characters. Omit to clear.
featured_image_urloptionalstringclearedFeatured image URL. Must be a valid http(s) URL. Omit to clear.

Request Body

{
  "title": "How We Cut Onboarding Time in Half (2026 update)",
  "format": "markdown",
  "content": "## The problem\n\nNew accounts took three days to activate.\n\nWe fixed it in two steps.",
  "excerpt": "A short summary shown in list views.",
  "meta_description": "How we cut onboarding time in half with two workflow changes.",
  "author": "Alex Morgan",
  "category": "Operations",
  "tags": ["onboarding", "ops"],
  "featured_image_alt": "Before and after onboarding timeline"
}

Response

{
  "id": "0f9c1e7a-4b2d-4a11-9c3e-2a7f8b6d5c40",
  "slug": "cut-onboarding-time-in-half",
  "status": "published",
  "edit_url": "https://app.personnapress.com/articles/0f9c1e7a-4b2d-4a11-9c3e-2a7f8b6d5c40",
  "created_at": "2026-09-20T14:02:11+00:00",
  "updated_at": "2026-09-22T09:15:44+00:00",
  "updated": true
}

Unpublish an Article

Take a published article down. This is a reversible unpublish, not a destructive delete: the article's status flips to hidden and it disappears from every public read endpoint, but the row, its content, and its full revision history are preserved. You can bring it back with a later PUT or by publishing it again in the app. Nothing is ever hard-deleted through this API.

Requires a Bearer ppw_ write token; a read-only ppd_ token returns 403 WRITE_SCOPE_REQUIRED. The call is idempotent: unpublishing an already-hidden article is a no-op that still returns 200. An unknown or another client's id returns 404 ARTICLE_NOT_FOUND; a non-UUID id returns 422.

DELETE/public/v1/authored/articles/{id}Soft unpublish: set status to hidden, preserving the row and revisions.

Path parameter: id (UUID, required) is the article's id.

Response

{
  "id": "0f9c1e7a-4b2d-4a11-9c3e-2a7f8b6d5c40",
  "slug": "cut-onboarding-time-in-half",
  "status": "hidden",
  "edit_url": "https://app.personnapress.com/articles/0f9c1e7a-4b2d-4a11-9c3e-2a7f8b6d5c40",
  "created_at": "2026-09-20T14:02:11+00:00",
  "updated_at": "2026-09-22T09:20:03+00:00",
  "updated": true
}

Full loop: create, read back, update, then unpublish (cURL example)

# Step 1: create an article and capture the id
RESPONSE=$(curl --silent --fail-with-body \
  -X POST "https://api.personnapress.com/public/v1/articles" \
  -H "Authorization: Bearer ppw_your_write_token_here" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "How We Cut Onboarding Time in Half",
    "format": "markdown",
    "content": "## The problem\n\nNew accounts took three days to activate."
  }')

ARTICLE_ID=$(echo "$RESPONSE" | jq -r '.id')

# Step 2: read it back by id to confirm the stored state
curl --silent --fail-with-body \
  "https://api.personnapress.com/public/v1/authored/articles/$ARTICLE_ID" \
  -H "Authorization: Bearer ppw_your_write_token_here"

# Step 3: update it in place (full replace, slug stays the same).
# If the article is published this edit is live immediately.
curl --silent --fail-with-body \
  -X PUT "https://api.personnapress.com/public/v1/authored/articles/$ARTICLE_ID" \
  -H "Authorization: Bearer ppw_your_write_token_here" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "How We Cut Onboarding Time in Half (updated)",
    "format": "markdown",
    "content": "## The problem\n\nNew accounts took three days to activate. Now they take one."
  }'

# Step 4: unpublish it (reversible soft delete -> status becomes hidden).
# The row and its revision history are preserved.
curl --silent --fail-with-body \
  -X DELETE "https://api.personnapress.com/public/v1/authored/articles/$ARTICLE_ID" \
  -H "Authorization: Bearer ppw_your_write_token_here"

Cache propagation

Edits and unpublishes are not instantly visible to already-cached public consumers. The public read routes carry public, max-age=60, stale-while-revalidate=300, so a CDN or browser can keep serving the old content, or a withdrawn post, for up to that window before it revalidates. Plan for eventual, not instant, propagation.

Error Reference

API error codes, HTTP status codes, and conditions that trigger them
Error CodeHTTP StatusWhen it fires
INVALID_DELIVERY_TOKEN401Token missing, malformed, revoked, or does not match any active token.
WRITE_SCOPE_REQUIRED403A valid read-only (ppd_) token was used on a write endpoint (create, read-back, update, or unpublish). Use a write (ppw_) token.
ARTICLE_NOT_FOUND404Slug or id does not exist, article is hidden (public read), or belongs to another client. Also returned by update and unpublish for an unknown or other-client id.
SLUG_CONFLICT_PUBLISHED409Create request used a slug that already belongs to a published article. Published posts are never overwritten via the API.
CONTENT_TOO_LARGE413Request body exceeds the 200 KB limit.
VALIDATION_ERROR422Create or update request body failed validation (missing/oversized field, bad format, unknown field, a non-UUID id, or a slug sent to the update endpoint where it is immutable).
RATE_LIMIT_EXCEEDED429More than 120 requests per minute (reads) or 60 per minute (writes) for this token.
INTERNAL_ERROR500Unexpected server error. Retry with exponential backoff.

Error response shape

{
  "detail": {
    "error": {
      "code": "INVALID_DELIVERY_TOKEN",
      "message": "Missing or invalid delivery token."
    }
  }
}

Caching

All successful responses include Cache-Control: public, max-age=60, stale-while-revalidate=300. CDNs and browsers may cache the response for 60 seconds and continue serving it stale for up to 300 seconds while revalidating in the background.

All responses also include an ETag header. Send it back as If-None-Match on subsequent requests to receive 304 Not Modified and save bandwidth.

Response headers

Cache-Control: public, max-age=60, stale-while-revalidate=300
ETag: W/"a3f9bc12de56f789"

Next.js revalidation pattern

const res = await fetch(`${API}/articles/${slug}`, {
  headers: { Authorization: `Bearer ${TOKEN}` },
  next: { revalidate: 60 },  // ISR: re-fetch at most every 60 s
});

Note: 401, 404, and 429 responses are sent with Cache-Control: no-store and must not be cached.

Code Examples

Copy-paste samples for common environments. The endpoint URL and header format are the same in every language.

cURL

# List articles
curl --silent --fail-with-body \
  "https://api.personnapress.com/public/v1/articles?page_size=20" \
  -H "Authorization: Bearer ppd_your_token_here"

# Get a single article
curl --silent --fail-with-body \
  "https://api.personnapress.com/public/v1/articles/how-to-price-consulting-services" \
  -H "Authorization: Bearer ppd_your_token_here"

Plain fetch

const TOKEN = "ppd_your_token_here";
const API = "https://api.personnapress.com/public/v1";

// List articles (index page)
const listRes = await fetch(`${API}/articles?page_size=20`, {
  headers: { Authorization: `Bearer ${TOKEN}` },
});
if (!listRes.ok) throw new Error(`HTTP ${listRes.status}`);
const { data, meta } = await listRes.json();

// Get a single article (detail page)
const articleRes = await fetch(
  `${API}/articles/how-to-price-consulting-services`,
  { headers: { Authorization: `Bearer ${TOKEN}` } }
);
if (!articleRes.ok) throw new Error(`HTTP ${articleRes.status}`);
const article = await articleRes.json();

document.querySelector("h1").textContent = article.title;
document.querySelector("article").innerHTML = article.html;

Next.js App Router

// app/blog/[slug]/page.tsx
import type { Metadata } from "next";

const API = "https://api.personnapress.com/public/v1";
const TOKEN = process.env.PERSONNAPRESS_DELIVERY_TOKEN!;

async function getArticle(slug: string) {
  const res = await fetch(`${API}/articles/${slug}`, {
    headers: { Authorization: `Bearer ${TOKEN}` },
    next: { revalidate: 60 },
  });
  if (!res.ok) return null;
  return res.json();
}

export async function generateStaticParams() {
  const res = await fetch(`${API}/articles?page_size=50`, {
    headers: { Authorization: `Bearer ${TOKEN}` },
    next: { revalidate: 3600 },
  });
  if (!res.ok) return [];
  const { data } = await res.json();
  return data.map(({ slug }: { slug: string }) => ({ slug }));
}

export async function generateMetadata(
  { params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
  const { slug } = await params;
  const article = await getArticle(slug);
  if (!article) return {};
  return {
    title: article.title,
    ...(article.seo.meta_description && {
      description: article.seo.meta_description,
    }),
    ...(article.seo.og && { openGraph: article.seo.og }),
  };
}

export default async function BlogPostPage(
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params;
  const article = await getArticle(slug);
  if (!article) return <p>Article not found.</p>;
  return (
    <main>
      <h1>{article.title}</h1>
      <article dangerouslySetInnerHTML={{ __html: article.html }} />
    </main>
  );
}

Astro

---
// src/pages/blog/[slug].astro
const { slug } = Astro.params;
const res = await fetch(
  `https://api.personnapress.com/public/v1/articles/${slug}`,
  {
    headers: {
      Authorization: `Bearer ${import.meta.env.PERSONNAPRESS_TOKEN}`,
    },
  }
);
if (!res.ok) return Astro.redirect("/404");
const article = await res.json();
---

<html lang="en">
  <head>
    <title>{article.title}</title>
    {article.seo.meta_description && (
      <meta name="description" content={article.seo.meta_description} />
    )}
    {article.seo.og?.title && (
      <meta property="og:title" content={article.seo.og.title} />
    )}
    {article.seo.og?.image && (
      <meta property="og:image" content={article.seo.og.image} />
    )}
    <script
      type="application/ld+json"
      set:html={JSON.stringify(article.seo.json_ld)}
    />
  </head>
  <body>
    <img
      src={article.featured_image_url}
      alt={article.featured_image_alt ?? article.title}
    />
    <h1>{article.title}</h1>
    <article set:html={article.html} />
  </body>
</html>

SvelteKit

// src/routes/blog/[slug]/+page.server.ts
import type { PageServerLoad } from "./$types";
import { error } from "@sveltejs/kit";

export const load: PageServerLoad = async ({ params, fetch }) => {
  const res = await fetch(
    `https://api.personnapress.com/public/v1/articles/${params.slug}`,
    {
      headers: {
        Authorization: `Bearer ${import.meta.env.PERSONNAPRESS_TOKEN}`,
      },
    }
  );
  if (!res.ok) {
    throw error(404, "Article not found");
  }
  const article = await res.json();
  return { article };
};