Connect Content Studio to your website or app

Copy-paste code to serve Content Studio pages from Next.js, Nuxt, SvelteKit, Astro, Eleventy, Hugo, plain HTML, WordPress and PHP, Django, Rails and mobile apps.

Content Studio is headless: you write and publish pages in AxisIQ, and your website or app reads them over a small public API. Your site can be built with anything, from a single HTML file to a framework that renders on the server, a static site generator, a PHP or Python backend, or a mobile app.

This guide gives working code for the common stacks. The full list of fields, endpoints and limits is in the Content Studio delivery API reference.

Before you start

  1. Create a content space and publish one page. Publishing a website from Content Studio walks through it.
  2. Open it to the public. In Team & Permissions → Roles, create a role from the Public visitors template with Published pages set to Readable by anyone. Until you do, every public URL returns 404.
  3. Copy your base URL. Open the content space's settings in Content Studio. The Delivery API & discovery section lists your URLs. They all start with:
https://axisiq.co/content/{orgID}/{bucketKey}

The examples below read it from an environment variable:

AXIS_CONTENT=https://axisiq.co/content/{orgID}/{bucketKey}

Every integration does the same two things:

  • Resolve the visitor's URL path to a published document: GET $AXIS_CONTENT/resolve?path=...
  • List a folder for index pages: GET $AXIS_CONTENT/list?prefix=blog/

A resolved document gives you title, body (markdown), html (the same body rendered to safe HTML), seo and tags. Image URLs are already absolute, so they work on your domain without extra setup.

Choosing an approach

Your site Fetch content When a publish goes live
Server-rendered (Next.js, Nuxt, SvelteKit, Astro in SSR mode, Remix, PHP, Django, Rails) On each request, cached for 60 seconds Within a minute, with no deploy
Static site generator (Astro static, Eleventy, Hugo, Gatsby) At build time At your next build
Client-rendered single-page app, or plain HTML In the browser Within a minute, with no deploy
Mobile app From the app Next time the app fetches

Server rendering is the best default for a public website. Search engines, AI crawlers and link-preview bots receive the full page on the first request, and publishing needs no rebuild.

Next.js (App Router)

One catch-all route serves every published page. revalidate: 60 caches each response for a minute, which keeps a busy site well inside the rate limit.

// app/[[...slug]]/page.tsx
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'

const BASE = process.env.AXIS_CONTENT!

async function getPage(slug: string[] = []) {
  const res = await fetch(`${BASE}/resolve?path=${encodeURIComponent(slug.join('/'))}`, {
    next: { revalidate: 60 },
  })
  if (res.status === 404) return null
  if (!res.ok) throw new Error(`Content API ${res.status}`)
  return (await res.json()).data
}

type Props = { params: Promise<{ slug?: string[] }> }

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const page = await getPage((await params).slug)
  if (!page) return {}
  return {
    title: page.seo.title || page.title,
    description: page.seo.description,
    alternates: page.seo.canonical_url ? { canonical: page.seo.canonical_url } : undefined,
    robots: page.seo.noindex ? { index: false } : undefined,
    openGraph: {
      title: page.seo.og_title || page.seo.title || page.title,
      description: page.seo.og_description || page.seo.description,
      images: page.seo.og_image ? [page.seo.og_image] : undefined,
    },
  }
}

export default async function Page({ params }: Props) {
  const page = await getPage((await params).slug)
  if (!page) notFound()
  return (
    <article className="prose">
      <h1>{page.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: page.html }} />
    </article>
  )
}

The html field is sanitized by AxisIQ: raw HTML inside a document is escaped, and javascript: links are removed. That is what makes dangerouslySetInnerHTML acceptable here. If you would rather render markdown with your own components, pass page.body to react-markdown instead.

A blog index lists a folder:

// app/blog/page.tsx
import Link from 'next/link'

export default async function Blog() {
  const res = await fetch(`${process.env.AXIS_CONTENT}/list?prefix=blog/`, { next: { revalidate: 60 } })
  const { documents } = (await res.json()).data
  return (
    <ul>
      {documents.map((d: any) => (
        <li key={d.path}>
          <Link href={`/${d.path}`}>{d.title}</Link>
          <p>{d.seo.description}</p>
        </li>
      ))}
    </ul>
  )
}

Nuxt 3

<!-- pages/[...slug].vue -->
<script setup lang="ts">
const route = useRoute()
const base = useRuntimeConfig().public.axisContent
const path = ([] as string[]).concat(route.params.slug || []).join('/')

const { data: page } = await useFetch(`${base}/resolve`, {
  query: { path },
  transform: (r: any) => r.data,
})
if (!page.value) throw createError({ statusCode: 404, statusMessage: 'Not found' })

useSeoMeta({
  title: page.value.seo.title || page.value.title,
  description: page.value.seo.description,
  ogImage: page.value.seo.og_image,
  robots: page.value.seo.noindex ? 'noindex' : undefined,
})
</script>

<template>
  <article class="prose">
    <h1>{{ page.title }}</h1>
    <div v-html="page.html" />
  </article>
</template>

Set runtimeConfig.public.axisContent in nuxt.config.ts from the AXIS_CONTENT variable. To cache server-side, add a route rule such as routeRules: { '/**': { swr: 60 } }.

SvelteKit

// src/routes/[...path]/+page.server.ts
import { error } from '@sveltejs/kit'
import { AXIS_CONTENT } from '$env/static/private'

export async function load({ params, fetch, setHeaders }) {
  const res = await fetch(`${AXIS_CONTENT}/resolve?path=${encodeURIComponent(params.path)}`)
  if (res.status === 404) error(404, 'Not found')
  setHeaders({ 'cache-control': 'public, max-age=60' })
  return { page: (await res.json()).data }
}
<!-- src/routes/[...path]/+page.svelte -->
<script>
  let { data } = $props()
</script>

<svelte:head>
  <title>{data.page.seo.title || data.page.title}</title>
  <meta name="description" content={data.page.seo.description ?? ''} />
</svelte:head>

<article class="prose">
  <h1>{data.page.title}</h1>
  {@html data.page.html}
</article>

Astro

Astro can fetch on each request (server output) or at build time (static output). The page code is the same; only the routing changes.

---
// src/pages/[...slug].astro (server output)
const base = import.meta.env.AXIS_CONTENT
const res = await fetch(`${base}/resolve?path=${encodeURIComponent(Astro.params.slug ?? '')}`)
if (res.status === 404) return new Response(null, { status: 404 })
const page = (await res.json()).data
Astro.response.headers.set('Cache-Control', 'public, max-age=60')
---
<html lang="en">
  <head>
    <title>{page.seo.title || page.title}</title>
    <meta name="description" content={page.seo.description} />
    {page.seo.og_image && <meta property="og:image" content={page.seo.og_image} />}
  </head>
  <body>
    <article class="prose">
      <h1>{page.title}</h1>
      <Fragment set:html={page.html} />
    </article>
  </body>
</html>

For a fully static build, add getStaticPaths that builds one page per published document:

export async function getStaticPaths() {
  const res = await fetch(`${import.meta.env.AXIS_CONTENT}/list`)
  const { documents } = (await res.json()).data
  return documents.map((d) => ({ params: { slug: d.path || undefined } }))
}

Static site generators

A static build fetches content once and writes HTML files. Publishing in Content Studio does not trigger your build, because there is no publish webhook yet. Choose one of these:

  • Build on a schedule. Netlify, Vercel, Cloudflare Pages and GitHub Actions can all run a build every hour or every night. GitHub Actions example: on: { schedule: [{ cron: '0 * * * *' }] }.
  • Build by hand after publishing. Most hosts give you a build hook URL; save it as a browser bookmark.
  • Switch to server rendering if pages must go live the moment they are published.

Eleventy: fetch every document in a global data file, then paginate over it to write one page each.

// _data/pages.js
module.exports = async () => {
  const base = process.env.AXIS_CONTENT
  const { documents } = (await (await fetch(`${base}/list`)).json()).data
  return Promise.all(
    documents.map(async (d) =>
      (await (await fetch(`${base}/resolve?path=${encodeURIComponent(d.path)}`)).json()).data,
    ),
  )
}
---
pagination: { data: pages, size: 1, alias: page }
permalink: "/{{ page.path }}/"
---
<h1>{{ page.title }}</h1>
{{ page.html | safe }}

Hugo: fetch the JSON with resources.GetRemote and print html with safeHTML. Hugo builds pages from files, so a common pattern is a small script that runs before hugo and writes one markdown file per document from the list and resolve responses. The body field is ready-made Hugo content.

A site that fetches 60 or more pages per build should space out its requests or reuse list results where it can, to stay under the rate limit of 240 requests a minute.

Plain HTML and client-side apps

The delivery API accepts browser requests from any domain, so a static HTML page or a React, Vue or Svelte single-page app can call it directly with fetch.

<article id="post"><p>Loading…</p></article>
<script type="module">
  const BASE = 'https://axisiq.co/content/{orgID}/{bucketKey}'
  const path = location.pathname.replace(/^\/|\/$/g, '')
  const res = await fetch(`${BASE}/resolve?path=${encodeURIComponent(path)}`)
  const el = document.getElementById('post')
  if (!res.ok) {
    el.textContent = 'Page not found.'
  } else {
    const { data } = await res.json()
    document.title = data.seo.title || data.title
    el.innerHTML = `<h1></h1>${data.html}`
    el.querySelector('h1').textContent = data.title
  }
</script>

Configure your host to serve this page for every path, so that example.com/blog/hello loads it and resolves blog/hello.

Client-side rendering has a cost. AI crawlers, Bingbot's first pass and the bots that build link previews in WhatsApp, Slack and LinkedIn do not run JavaScript, so they see an empty page. You have two options:

  • Prefer server rendering with one of the frameworks above.
  • Send those readers to the render endpoint. GET $AXIS_CONTENT/render/{path} returns the same published content as a complete HTML page, with your SEO tags, Open Graph tags and structured data, and a real 404 for unknown paths. It is unstyled by design. Route crawler traffic to it at your edge, for example in nginx:
location / {
  if ($http_user_agent ~* "(googlebot|bingbot|gptbot|claudebot|perplexitybot|facebookexternalhit|twitterbot|linkedinbot|slackbot|whatsapp)") {
    rewrite ^/(.*)$ /content/{orgID}/{bucketKey}/render/$1 break;
    proxy_pass https://axisiq.co;
  }
  proxy_ssl_server_name on;
  try_files $uri /index.html;
}

Bots and visitors get the same content, so this is dynamic rendering, not cloaking. You do need to keep the list of bot user agents up to date yourself.

WordPress, PHP and Laravel

<?php
$base = getenv('AXIS_CONTENT');
$path = trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/');

$cacheKey = 'axis_' . md5($path);
$page = function_exists('apcu_fetch') ? apcu_fetch($cacheKey) : false;
if ($page === false) {
    $json = @file_get_contents($base . '/resolve?path=' . rawurlencode($path));
    $page = $json === false ? null : json_decode($json, true)['data'];
    if (function_exists('apcu_store')) apcu_store($cacheKey, $page, 60);
}
if (!$page) { http_response_code(404); exit('Not found'); }
?>
<title><?= htmlspecialchars($page['seo']['title'] ?? $page['title']) ?></title>
<meta name="description" content="<?= htmlspecialchars($page['seo']['description'] ?? '') ?>">
<article>
  <h1><?= htmlspecialchars($page['title']) ?></h1>
  <?= $page['html'] ?>
</article>

In WordPress, use wp_remote_get() and store the result with set_transient() for 60 seconds. In Laravel, use Http::get() inside Cache::remember().

Python: Django and Flask

# Django view
import os, requests
from django.core.cache import cache
from django.http import Http404
from django.shortcuts import render

BASE = os.environ["AXIS_CONTENT"]

def page(request, path=""):
    doc = cache.get(f"axis:{path}")
    if doc is None:
        r = requests.get(f"{BASE}/resolve", params={"path": path}, timeout=5)
        if r.status_code == 404:
            raise Http404
        r.raise_for_status()
        doc = r.json()["data"]
        cache.set(f"axis:{path}", doc, 60)
    return render(request, "page.html", {"page": doc})

# urls.py: path("<path:path>", page) and path("", page)

In the template, output {{ page.html|safe }}. A Flask view is the same with abort(404) and render_template.

Ruby on Rails

class PagesController < ApplicationController
  BASE = ENV.fetch("AXIS_CONTENT")

  def show
    path = params[:path].to_s
    @page = Rails.cache.fetch("axis:#{path}", expires_in: 60.seconds) do
      res = Net::HTTP.get_response(URI("#{BASE}/resolve?path=#{CGI.escape(path)}"))
      res.is_a?(Net::HTTPSuccess) ? JSON.parse(res.body)["data"] : nil
    end
    raise ActionController::RoutingError, "Not Found" unless @page
  end
end

# routes.rb: get "*path", to: "pages#show"; root "pages#show"

In the view, output <%= raw @page["html"] %>.

Mobile apps

iOS, Android, Flutter and React Native apps call the same URLs. Use whichever field suits your renderer:

  • body with a native markdown view, for example MarkdownUI on SwiftUI, Markwon on Android, flutter_markdown, or react-native-markdown-display.
  • html in a web view.

Image URLs are absolute, so they load as they are. Respect the Cache-Control headers, or cache in the app, so that content still shows when the device is offline.

Serve sitemap, robots and llms.txt from your domain

Content Studio generates sitemap.xml, robots.txt, llms.txt and llms-full.txt from what is published, on every request. Search engines and AI tools look for them at the root of your domain, so forward those paths to AxisIQ. First, set the content space's Site URL to your domain so the links inside the files point at your pages.

nginx

location ~ ^/(sitemap\.xml|robots\.txt|llms\.txt|llms-full\.txt)$ {
  proxy_pass https://axisiq.co/content/{orgID}/{bucketKey}/$1;
  proxy_ssl_server_name on;
}

Next.js (next.config.js)

module.exports = {
  async rewrites() {
    return ['sitemap.xml', 'robots.txt', 'llms.txt', 'llms-full.txt'].map((f) => ({
      source: `/${f}`,
      destination: `${process.env.AXIS_CONTENT}/${f}`,
    }))
  },
}

Netlify (netlify.toml)

[[redirects]]
  from = "/sitemap.xml"
  to = "https://axisiq.co/content/{orgID}/{bucketKey}/sitemap.xml"
  status = 200

Repeat the redirect for the other three files. On Vercel, use rewrites in vercel.json. On Cloudflare, use a Worker that calls fetch() on the AxisIQ URL.

Previewing drafts

The public API only serves published content. To show drafts on a staging site, give that site an access key and read through the management API from its server. The steps are in the API reference. Keep the key on the server and never ship it to a browser.

Checklist before launch

  • The Public visitors role has Published pages: Readable by anyone.
  • The content space's Site URL is your production domain.
  • Your server or CDN caches content responses for about a minute.
  • /sitemap.xml, /robots.txt and /llms.txt on your domain return AxisIQ's generated files.
  • Unknown paths return a real 404 status.
  • Every page has a meta description. Content Studio shows a warning dot in Page settings when one is missing.
  • Static sites: a scheduled or manual rebuild is in place for publishing.