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
- Create a content space and publish one page. Publishing a website from Content Studio walks through it.
- 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. - 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 real404for 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:
bodywith a native markdown view, for example MarkdownUI on SwiftUI, Markwon on Android,flutter_markdown, orreact-native-markdown-display.htmlin 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.txtand/llms.txton your domain return AxisIQ's generated files. - Unknown paths return a real
404status. - 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.