# Embed your blog inside your own site

Render your Inkhost blog **between your own header and footer**, on your own
domain, with full SEO — without us replicating your header. Your site fetches
the blog content server-side and drops it into your existing layout. Works with
Next.js, WordPress, PHP, Angular, or anything that can make an HTTP request on
the server.

> Prefer this over an `<iframe>`: iframes aren't indexed as part of your page, so
> they hurt SEO. Server-side composition keeps the content in your page's HTML,
> on your domain.

---

## How it works

1. Your site serves the blog under a path, e.g. `https://example.com/blog/...`.
2. For each blog URL, your server calls the Inkhost **content API** (or the
   **fragment** endpoint), gets back the post's SEO metadata + body HTML, and
   renders it inside your normal page template.
3. Canonical / Open Graph / sitemap URLs come back **already pointing at your
   domain**, so search engines index the content under `example.com/blog/...`.

You need three things from Inkhost:

- **`blogId`** — your blog's UUID (Dashboard → blog settings).
- **`APP_ORIGIN`** — the Inkhost app origin, `https://app.inkhost.app`.
- **`embed_key`** — the secret that authorizes your site (Dashboard → **Embed / API
  access** → Generate key). **Required** — send it as the `X-Embed-Key` header on
  every API request. Keep it server-side; never expose it in browser code.

To make the canonical/sitemap URLs match your hosting path, set your blog's
**subdirectory host & path** in the dashboard (e.g. host `example.com`,
path `/blog`). See `ask2-client-proxy-setup.md`.

### Authorization (required — locked by default)

Embedding is **locked until you generate a key**. Every API request must send the
secret:

```
X-Embed-Key: <your embed_key>      (or ?key=<embed_key>)
```

Responses by status: **403** = no key configured for this blog yet (generate one);
**401** = key missing or wrong; **404** = blog not found or embedding switched off.

Optional **browser-origin allowlist** (Dashboard → Embed / API access): restricts
which browser origins may read the API cross-site (defense-in-depth). It does
**not** protect server-to-server calls — the key does that. Leave empty to allow
any origin. Rotating the key invalidates the old one immediately.

---

## API reference

Base: `https://app.inkhost.app/api/embed/{blogId}`

| Endpoint | Returns |
| --- | --- |
| `GET /index?page=1&pageSize=50` | Paginated post list + tag cloud (build routes & sitemaps). |
| `GET /post/{slug}` | One post: `seo`, `jsonLd[]`, `bodyHtml`, `post`, `blog`. |
| `GET /tag/{slug}?page=1` | Posts for a tag (same envelope as `/index`). |
| `GET /theme.css` | The blog's stylesheet (`text/css`). Include it once. |
| `GET /fragment/post/{slug}` | `{ head, body, cssUrl }` — ready-to-echo HTML. Add `?format=html` for raw `head`+`body`. |

**Every endpoint requires the `X-Embed-Key` header** (or `?key=`). `bodyHtml` is
sanitized server-side. Image URLs in `seo.image` stay on the Inkhost origin (OG
renderer); everything else is on your domain.

### `/post/{slug}` shape (abridged)

```jsonc
{
  "blog":   { "id", "name", "origin", "basePath", "homeUrl", "themeCssUrl" },
  "post":   { "id","title","slug","excerpt","publishedAt","updatedAt",
              "readingMinutes","wordCount","coverImageUrl","author","tags" },
  "seo":    { "title","fullTitle","description","canonicalUrl","keywords",
              "favicon","og":{…}, "twitter":{…} },
  "jsonLd": [ { "@type": "Article" }, { "@type": "BreadcrumbList" } ],
  "bodyHtml": "<p>…sanitized post HTML…</p>",
  "themeCssUrl": "https://app.inkhost.app/api/embed/{blogId}/theme.css"
}
```

---

## Next.js (App Router)

A catch-all route on your site renders blog pages inside your layout.

```tsx
// app/blog/[[...slug]]/page.tsx
const API = `https://app.inkhost.app/api/embed/${process.env.BLOG_ID}`;
// Server-side only — never ship the key to the browser.
const opts = { headers: { "X-Embed-Key": process.env.EMBED_KEY! } };

export async function generateStaticParams() {
  const data = await fetch(`${API}/index?pageSize=100`, opts).then((r) => r.json());
  return data.posts.map((p: { slug: string }) => ({ slug: ["p", p.slug] }));
}

export async function generateMetadata({ params }) {
  const { slug = [] } = await params;
  if (slug[0] !== "p") return {};
  const { seo } = await fetch(`${API}/post/${slug[1]}`, opts).then((r) => r.json());
  return {
    title: seo.fullTitle,
    description: seo.description,
    alternates: { canonical: seo.canonicalUrl },
    openGraph: { ...seo.og },
    twitter: { ...seo.twitter },
  };
}

export default async function BlogPage({ params }) {
  const { slug = [] } = await params;
  const { seo, jsonLd, bodyHtml, themeCssUrl } =
    await fetch(`${API}/post/${slug[1]}`, opts).then((r) => r.json());
  return (
    <>
      <link rel="stylesheet" href={themeCssUrl} />
      {jsonLd.map((ld, i) => (
        <script key={i} type="application/ld+json"
          dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} />
      ))}
      <div className="public-themed public-themed--post">
        <article className="prose font-serif"
          dangerouslySetInnerHTML={{ __html: bodyHtml }} />
      </div>
    </>
  );
}
```

Your `app/blog/layout.tsx` provides your real header/footer.

---

## WordPress

Use the **fragment** endpoint. In a page template (or small plugin) mapped to
`/blog/...`:

```php
<?php /* Template Name: Blog Post */
$blog_id = 'YOUR_BLOG_ID';
$slug    = get_query_var('blog_slug');           // from your rewrite rule
$res     = wp_remote_get(
  "https://app.inkhost.app/api/embed/$blog_id/fragment/post/$slug",
  [ 'headers' => [ 'X-Embed-Key' => EMBED_KEY ] ]   // keep EMBED_KEY in wp-config.php
);
$frag    = json_decode(wp_remote_retrieve_body($res), true);

add_action('wp_head', fn() => print($frag['head']));   // canonical, OG, JSON-LD, CSS link
get_header();                                            // your theme's top bar
echo $frag['body'];                                      // the blog content
get_footer();                                            // your theme's bottom bar
```

Enqueue the stylesheet instead of the inline `<link>` if you prefer:
`wp_enqueue_style('inkhost', $frag['cssUrl']);`

---

## PHP (generic)

`RewriteRule ^blog/p/(.+)$ blog.php?slug=$1` then:

```php
<?php
$blog_id = 'YOUR_BLOG_ID';
$key     = getenv('EMBED_KEY');                       // keep the key server-side
$slug    = preg_replace('/[^a-z0-9\-]/', '', $_GET['slug']);
$ctx     = stream_context_create(['http' => ['header' => "X-Embed-Key: $key\r\n"]]);
$html    = file_get_contents(
  "https://app.inkhost.app/api/embed/$blog_id/fragment/post/$slug?format=html",
  false, $ctx
);
// $html is the <head> block + body block concatenated.
?>
<!doctype html><html><head>
  <?= $html /* contains <title>, meta, canonical, JSON-LD, stylesheet link */ ?>
</head><body>
  <?php include 'header.php'; /* your top bar */ ?>
  <?php /* body block is already inside $html after the head tags */ ?>
  <?php include 'footer.php'; /* your bottom bar */ ?>
</body></html>
```

For finer control, fetch the JSON `fragment` endpoint and place `head` and
`body` separately.

---

## Angular (SSR / Universal)

Angular must render on the server for crawlable content — use Angular Universal.
A wildcard route `/blog/**` resolves data server-side:

```ts
// blog.resolver.ts — fetch on the SERVER and pass to the browser via
// TransferState so the secret key is never sent to the browser.
const data = await firstValueFrom(
  http.get(`https://app.inkhost.app/api/embed/${blogId}/post/${slug}`,
    { headers: { 'X-Embed-Key': process.env['EMBED_KEY']! } })
);
title.setTitle(data.seo.fullTitle);
meta.updateTag({ name: 'description', content: data.seo.description });
meta.updateTag({ rel: 'canonical', href: data.seo.canonicalUrl });   // via <link> in head
// inject JSON-LD <script>, add <link rel="stylesheet" href={data.themeCssUrl}>
```

Bind the body with `[innerHTML]="bodyHtml | safeHtml"` (it's pre-sanitized
server-side). Wrap it in `<div class="public-themed public-themed--post">`.

---

## Sitemap, RSS & robots

Simplest: proxy these three paths from your domain to Inkhost, preserving the
original `Host` header — Inkhost already emits the correct subdirectory URLs:

```
example.com/blog/sitemap.xml  →  app.inkhost.app/sitemap.xml
example.com/blog/rss.xml      →  app.inkhost.app/rss.xml
example.com/blog/robots.txt   →  app.inkhost.app/robots.txt
```

(See `ask2-client-proxy-setup.md` for proxy snippets.) Alternatively, build your
own sitemap from the `/index` endpoint's `posts[].url` values.
