PendiumDocs

Use Your Own Front End

Write and publish in Pendium, then render the pages and posts on a site you build and deploy yourself, from one export endpoint.

Pendium can be the place your content is written and published while your own code renders it. Your site reads everything the brand has published from one endpoint, GET /api/v1/site/export, and serves it with your templates, on your domain, from your repo.

Pick how your content reaches the web

OptionWho renders itUse it when
A site built for youPendium, on a folder on your site or a subdomainYou're on Pro and want pages and posts live without building or running a site.
CMS mirrorWordPress, Webflow, or GhostYou already run a blog there and want posts copied into it. Pages aren't copied.
Your own front end (this page)Your codeYou want full control of the markup, design, and hosting, for posts and pages alike.

With your own front end, Pendium doesn't serve anything to your visitors. Your site fetches the export, renders it, and answers every request itself.

Where content and code change

You write, edit, publish, and schedule in Pendium, and the slug, categories, writer, and address of every page and post are set there too. The export reports all of it. Your templates, styling, and hosting live in your repo and deploy through your own pipeline, so a template change never touches Pendium.

A publish in Pendium changes what the export returns. Your site shows it the next time it reads the export: at build time for a static site, or per request (or per cache window) for a server-rendered one.

Pendium doesn't notify your site when content changes. A static site picks up new content by rebuilding, and the export's ETag makes a scheduled check cheap: an unchanged brand answers 304 Not Modified, so you only rebuild when something was published or edited. The quickstart below sets this up.

What your site needs to serve

To keep the addresses Pendium has already handed out working, serve:

  • every page at its path, and every listing page (indexes) at its path;
  • when site.postsEnabled is true, every post, category, and author at its path. With posts off, Pendium serves none of them, even though a post's path is still set;
  • a 301 from each aliases[].from to its to, so old slugs and the /blog address still land.

Paths are relative to the site root. Build full URLs (canonical tags, sitemap, feed) from site.canonicalOrigin plus path, or from your own origin when canonicalOrigin is null. Don't build them from the url fields: they point at the brand's live site, and they're null when the brand has no live site Pendium serves or builds for it (including a built site whose address isn't live yet, such as a newly verified domain that isn't serving the site). A post's url is also null when the site doesn't serve that post: its path is null, or posts are off. Paths lists the rules the export already applied, so your router doesn't have to re-derive them.

Page content arrives as typed sections (hero, prose, faq, and so on), not HTML, so your components decide the markup. Post bodies are Markdown. Links between the brand's own pages and posts already point at their site-relative path, so they work on your host as they are (Links in content). Site Export documents every field.

Analytics

Pendium can count visits to a site you render yourself, so the brand's Site Analytics keeps working. There are two parts: a tag for people, and a server-side report for AI crawlers.

Page views

Add the brand's tag to every public page, in the page's <head>:

<script defer src="https://pendium.ai/a.js" data-site="ps_123_xxxxxxxxxxxxxxxx"></script>

Copy it from the brand's Site Analytics page, under Running your own front end?, or build it from site.analytics.siteKey in the export. When site.analytics is null, analytics is off for the brand: leave the tag out.

  • Use a classic script tag. With type="module" the tag can't find its own data-site and does nothing.
  • It counts one view per page, including pages your router changes without a full load. An anchor link or a change to the query string isn't a new view.
  • It stores nothing on the visitor's device: no cookie, no local storage. A visitor counts once per day.
  • Leave it off preview and staging builds, or their visits count too.

Views count when the page is on a host the brand is served on: its verified custom domain, its verified subpath host, or the website on its company profile (with or without www).

AI crawlers

AI crawlers don't run JavaScript, so the tag never sees them. Report them from your server with POST /api/analytics/hit, signed with the brand's signing secret from the same Site Analytics card. The secret can only report visits for its own brand. Keep it on your server.

Quickstart: Astro

This builds a static Astro site that reads the export at build time. It renders posts and their redirects; add page and listing templates the same way once this works. The same shape fits Next.js, Eleventy, or any generator that can fetch JSON during a build.

1. Get an API key and the brand ID

Create a key from API Keys under your avatar in Pendium (see Authentication). Keep it on the build server: a preview that runs code you don't fully trust can read drafts with a reviewer's preview token alone, with no key. Find the brand's syntheticId with /api/visibility/account.

Put both in your build environment, never in client code. The key can read every brand your account can open.

# .env
PENDIUM_API_KEY=pendium_xxxxxxxxxxxx
PENDIUM_SYNTHETIC_ID=123

2. Load the export once per build

The first response carries up to 100 posts. Follow postsNextCursor until it's null to collect the rest.

// src/lib/pendium.ts
const ENDPOINT = "https://pendium.ai/api/v1/site/export";
 
async function fetchExport(params: Record<string, string> = {}) {
  const query = new URLSearchParams({
    syntheticId: import.meta.env.PENDIUM_SYNTHETIC_ID,
    ...params,
  });
  const res = await fetch(`${ENDPOINT}?${query}`, {
    headers: { Authorization: `Bearer ${import.meta.env.PENDIUM_API_KEY}` },
  });
  if (!res.ok) {
    throw new Error(`Pendium export failed: ${res.status} ${await res.text()}`);
  }
  return { data: await res.json(), etag: res.headers.get("etag") ?? "" };
}
 
async function fetchSite() {
  const { data: site, etag } = await fetchExport();
  let cursor = site.postsNextCursor;
  while (cursor) {
    const { data: batch } = await fetchExport({ postsCursor: cursor });
    site.posts.push(...batch.posts);
    cursor = batch.postsNextCursor;
  }
  // The version this build was made from; step 5 publishes it.
  return { ...site, etag };
}
 
// Every page in the build shares one fetch.
let cached: ReturnType<typeof fetchSite> | undefined;
export function loadSite() {
  cached ??= fetchSite();
  return cached;
}

3. Render posts at their paths

Install a Markdown renderer and an HTML sanitizer for the post bodies:

npm i marked sanitize-html

Post bodies are Markdown, and Markdown can carry raw HTML that marked passes through untouched. Sanitize the output before it goes into the page, so a stray <script> or onclick in a post can't run for your visitors.

Serve posts only when site.postsEnabled is true. A post whose path is null isn't reachable on the site (a page owns that address, for example), so skip it too.

---
// src/pages/[...path].astro
import { marked } from "marked";
import sanitizeHtml from "sanitize-html";
import { loadSite } from "../lib/pendium";
 
export async function getStaticPaths() {
  const site = await loadSite();
  if (!site.site.postsEnabled) return [];
  return site.posts
    .filter((post) => post.path)
    .map((post) => ({
      params: { path: post.path.slice(1) },
      props: { post },
    }));
}
 
const { post } = Astro.props;
const html = sanitizeHtml(await marked.parse(post.body), {
  allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]),
});
---
<html lang="en">
  <head>
    <title>{post.title}</title>
    {post.metaDescription && <meta name="description" content={post.metaDescription} />}
  </head>
  <body>
    <article>
      <h1>{post.title}</h1>
      {post.heroImageUrl && <img src={post.heroImageUrl} alt="" />}
      <Fragment set:html={html} />
    </article>
  </body>
</html>

4. Carry over the redirects

Astro config files can await, so load the export there and turn aliases into redirects.

// astro.config.mjs
import { defineConfig } from "astro/config";
import { loadEnv } from "vite";
 
const env = loadEnv(process.env.NODE_ENV ?? "production", process.cwd(), "");
const res = await fetch(
  `https://pendium.ai/api/v1/site/export?syntheticId=${env.PENDIUM_SYNTHETIC_ID}`,
  { headers: { Authorization: `Bearer ${env.PENDIUM_API_KEY}` } }
);
if (!res.ok) throw new Error(`Pendium export failed: ${res.status}`);
const { aliases } = await res.json();
 
export default defineConfig({
  redirects: Object.fromEntries(
    aliases.map(({ from, to }) => [from, { status: 301, destination: to }])
  ),
});

With a host adapter (Netlify, Vercel, Cloudflare), Astro writes these as real 301s in the host's config. A plain static build without an adapter gets a meta-refresh page at each old address instead, so check what your host supports.

5. Rebuild when content changes

Most hosts give you a deploy hook: a URL that starts a build when you POST to it. The hook only starts the build, and the build can still fail after the hook answers. So let the deployed site record which version of the export it was built from, and compare against that.

Publish the build's ETag as a file on the site:

// src/pages/pendium-etag.txt.ts
import { loadSite } from "../lib/pendium";
 
export async function GET() {
  const site = await loadSite();
  return new Response(site.etag);
}

Then run a check on a schedule (a CI cron, for example) that reads that file from your live site and calls the hook only when the export has changed since:

// scripts/check-pendium.mjs
const live = await fetch("https://www.example.com/pendium-etag.txt");
const deployed = live.ok ? await live.text() : "";
 
const res = await fetch(
  `https://pendium.ai/api/v1/site/export?syntheticId=${process.env.PENDIUM_SYNTHETIC_ID}`,
  {
    headers: {
      Authorization: `Bearer ${process.env.PENDIUM_API_KEY}`,
      ...(deployed && { "If-None-Match": deployed }),
    },
  }
);
 
if (res.status === 304) {
  console.log("The live site is up to date.");
} else if (res.ok) {
  const hook = await fetch(process.env.DEPLOY_HOOK_URL, { method: "POST" });
  if (!hook.ok) throw new Error(`Deploy hook failed: ${hook.status}`);
  console.log("Content changed; build started.");
} else {
  throw new Error(`Pendium export failed: ${res.status}`);
}

The file changes only when a build deploys, so a failed build leaves the old ETag live and the next check starts another build. A check that runs while a build is still going can start a second one; many hosts queue or cancel the duplicate. The ETag covers the whole brand, including posts past the first 100, so one export request per check is enough. Every request counts toward your rate limit, 304s included, so every few minutes is plenty.

Previewing drafts

A static build shows published content only. To let a reviewer see unpublished pages and posts, render a preview route on demand (server-side) that requests perspective=drafts with the reviewer's preview token in the X-Pendium-Preview-Token header. Drafts need that token as well as your API key, so your public build can't show them by accident. Drafts covers the token and how long it lasts.