Headless or Coupled? Serve Mishka CMS Content as JSON:API

A practical guide to the three site modes of Mishka CMS, and to reading posts, pages and documents over the JSON:API from curl, JavaScript and Elixir.

Headless or Coupled? Serve Mishka CMS Content as JSON:API

A Mishka CMS site can draw its own pages, serve its content as JSON to another app, or do both. If another app draws your pages, set the site to API Only. If the CMS draws them, keep Phoenix Only or the default Hybrid. This guide shows how to choose and then read your Mishka CMS posts with curl, JavaScript and Elixir.

Time left until the offer ends
119
D
08
H
13
M
48
S
Claim your seat · $250One payment, no subscription. Questions first? Contact us.

Mishka CMS stays free. A founding seat pays for the time we spend on it and on its extensions, and it gives you lifetime access, priority support and every paid extension we release.

Everything below ran against a local Mishka CMS host while we wrote it, and the output you see is real. The route and permission details come from the CMS docs, which this post links as it goes.

You need about ten minutes and a terminal. By the end you have a small frontend that lists posts from the CMS and loads more on a click.

Newsletter

Mishka CMS releases. Once a month.

New Mishka CMS releases, Elixir and Phoenix patterns, and case studies from the team. No spam, unsubscribe anytime.

✓ once a month✓ no spam
3site modes
0tokens to read public posts
20 / 100default and maximum page size
4route families under /api/json

The short answer

Use coupled when the CMS should draw the website, and headless when a separate app should. Mishka CMS lets each site pick one of three modes, and the same content serves both.

1 · ModePick the modeAPI Only or Hybrid, on the Sites screen
2 · ReadFetch postsOne GET, no token
3 · ShapePage and filterpage, sort, filter, fields
4 · AllowOpen CORSFrontend Domain or Allowed Origins
5 · DrawRender itYour app, your markup
Diagram of a coupled Mishka CMS site, where the CMS draws the HTML, next to a headless site, where a separate frontend asks the JSON:API and draws it

Coupled: the CMS draws the page. Headless: your app draws it from JSON.

Coupled is the easy path. A route at the end of the host's router sends every unclaimed path to one LiveView, which finds the site from the host name, finds the page and renders it inside its layout. Pages, layouts and components are database rows, so a publish compiles while the app runs and nothing is redeployed. Headless or coupled in the docs explains it in full.

Headless keeps your own frontend. The CMS holds the content and the admin, and your app reads the JSON:API under /api/json.

Pick the mode in two questions

Every site has a Mode on the Sites screen, under Runtime in the sidebar, and only a master can change it. The default is Hybrid.

Decision diagram that picks API Only, Hybrid or Phoenix Only for a Mishka CMS site from two questions

Two questions pick the mode.

ModeWeb pagesJSON:APIUse it for
HybridServedServedThe default: the CMS draws pages and other apps still read and write
Phoenix OnlyServedRefusedA website that needs no API
API OnlyRefusedServedA site whose frontend is a separate app

A Phoenix Only site answers an API request with status 403 and the code api_access_denied. An API Only site answers a web visit with a 403 page that reads Web Access Not Allowed.

Read posts with one request

Published posts need no token. Send Accept: application/vnd.api+json and the Host of your site, and you get the posts as JSON:API blog_post resources.

curl -g "https://cms.example.com/api/json/v1/user/mishka-blog/post/published?page[limit]=2&sort=-published_at&fields[blog_post]=title,slug,published_at" \
  -H "Accept: application/vnd.api+json"

The -g flag stops curl from reading the brackets as a range. This is the real answer from a local host. Each record also carries a relationships block, which we left out here.

A terminal running curl against the Mishka CMS published posts route and the JSON:API answer with two blog_post records

The answer of a real local Mishka CMS host. Each record also carries a relationships block, left out here.

The request finds its site from the Host header. That is why the same address can serve many sites, and why an unknown host answers 404 with the code domain_not_found.

Animation of a frontend sending a GET request to Mishka CMS, the CMS finding the site from the Host header, answering with three blog posts as JSON:API, the frontend drawing them, and a newly published post appearing on the next fetch

One request, one answer, one page. The titles are real answers from a local demo host; the new post is an illustration.

Page, sort, filter and pick fields

Lists page by offset: 20 records by default and 100 at most. Ask for more and the CMS returns 100 and stops. We asked for page[limit]=500 and got 100 back.

Four query parameters cover most frontends. We ran each one against the local host.

pagepage[limit], page[offset], page[count]Size, start and, with page[count]=true, the total in meta.page.total.
sortsort=-published_atNewest first. A minus sign reverses the order.
filterfilter[slug]=..., filter[language]=enNarrow a list. filter[title][contains]=Phoenix finds a word in titles.
fieldsfields[blog_post]=title,slugSend only the attributes you draw. The answer shrinks a lot.
# Page 2 of 2 posts each, newest first, with the total count
curl -g "https://cms.example.com/api/json/v1/user/mishka-blog/post/published?page[limit]=2&page[offset]=2&page[count]=true&sort=-published_at&fields[blog_post]=title,published_at" \
  -H "Accept: application/vnd.api+json"

# One post by its slug
curl -g "https://cms.example.com/api/json/v1/user/mishka-blog/post/published?filter[slug]=changelog-the-small-fixes-you-asked-for&fields[blog_post]=title" \
  -H "Accept: application/vnd.api+json"

# Posts of one language, or with a word in the title
curl -g "https://cms.example.com/api/json/v1/user/mishka-blog/post/published?filter[language]=en&filter[title][contains]=Phoenix&fields[blog_post]=title" \
  -H "Accept: application/vnd.api+json"

With page[count]=true the answer carries the total next to the offset, and a links.next address when another page exists:

{"page": {"offset": 2, "total": 303, "limit": 2}}

Your total will differ. Follow links.next until it is null, and you have read every post.

Build a small frontend

This script reads three posts and prints a line for each. It needs Node 18 or newer and no packages.

JavaScript

// Node 18+ : node posts.mjs
const base = "https://cms.example.com/api/json/v1/user/mishka-blog";
const query = "page[limit]=3&sort=-published_at&fields[blog_post]=title,slug,published_at";

const res = await fetch(`${base}/post/published?${query}`, {
  headers: { accept: "application/vnd.api+json" },
});
if (!res.ok) throw new Error(`CMS answered ${res.status}`);

const { data, links } = await res.json();
for (const { attributes: p } of data) {
  console.log(`${p.published_at.slice(0, 10)}  ${p.title}  -> /blog/${p.slug}`);
}
console.log("next page:", links.next ? "yes" : "no");

We ran it against the local host and it printed:

2026-10-27  Changelog: the small fixes you asked for  -> /blog/changelog-the-small-fixes-you-asked-for
2026-10-12  A new partner program for agencies  -> /blog/a-new-partner-program-for-agencies
2026-09-25  Ecto-Migrationen ohne Ausfallzeit (Teil 7)  -> /blog/ecto-migrationen-ohne-ausfallzeit-7
next page: yes

Elixir

The same request from Elixir, with Req. Run it with elixir posts.exs.

elixir
Mix.install([{:req, "~> 0.5"}])

base = "https://cms.example.com/api/json/v1/user/mishka-blog"

resp =
  Req.get!("#{base}/post/published",
    headers: [accept: "application/vnd.api+json"],
    params: [
      "page[limit]": 3,
      sort: "-published_at",
      "fields[blog_post]": "title,slug,published_at"
    ]
  )

for %{"attributes" => p} <- resp.body["data"] do
  IO.puts("#{String.slice(p["published_at"], 0, 10)}  #{p["title"]}  -> /blog/#{p["slug"]}")
end

It printed the same three lines. Put the same two parameters into a LiveView, a Next.js page or a mobile app, and you have a frontend. Ours is a plain HTML page of about forty lines with a Load more button that follows links.next.

A small separate frontend called AcmeDev that lists three blog posts fetched from the Mishka CMS JSON:API with a Load more button

The 40-line frontend from this post, reading a local Mishka CMS host.

Now compare it with the same site in coupled mode, where the CMS draws the blog itself.

The same Mishka CMS blog drawn by the CMS itself on its own address, with the blog heading and a post count of 301 posts

The same site in coupled mode: the CMS draws the blog.

Sign in for admin routes

Reading public content needs no token. Reading drafts, pages and layouts, or writing anything, needs one. The routes fall into four families, and the ladder runs from open to master.

Diagram of four Mishka CMS JSON:API route families: readers with no token, sign in, site admins with admin:api names, and masters across sites

Reader routes need no token. Admin routes need an admin:api: name.

Sign in with a POST. A 201 answer holds the account in data and the token in meta.token. Send it as Authorization: Bearer on later requests.

curl -X POST "https://cms.example.com/api/json/v1/auth/user/sign-in" \
  -H "Accept: application/vnd.api+json" \
  -H "Content-Type: application/vnd.api+json" \
  -d '{"data": {"type": "user", "attributes": {"email": "[email protected]", "password": "your-password"}}}'
# The token is in the answer: {"meta": {"token": "..."}}

curl -g "https://cms.example.com/api/json/v1/tenant/admin/runtime/page?fields[page]=title,path" \
  -H "Accept: application/vnd.api+json" \
  -H "Authorization: Bearer $TOKEN"

Admin routes check names that start with admin:api:. A name such as admin:dashboard:* opens nothing on the API. Reading takes any name under admin:api, and writing takes the area's own name, such as admin:api:runtime:page for pages. The page Permission names lists them all.

Without a token, an admin route answers 403 with the code forbidden. We checked both failures on the local host:

$ curl -g -i "http://localhost:4030/api/json/v1/tenant/admin/runtime/page" -H "Accept: application/vnd.api+json"
HTTP/1.1 403 Forbidden
{"errors":[{"code":"forbidden","status":"403","title":"Forbidden","detail":"forbidden"}],"jsonapi":{"version":"1.0"}}

$ curl -i "http://localhost:4030/api/json/v1/user/mishka-blog/post/published" -H "Host: nope.example.com"
HTTP/1.1 404 Not Found
{"error":{"code":"domain_not_found","message":"Domain not found","status":404,...}}

Call the API from a browser on another domain

A browser frontend works only after you allow its origin. The CMS sends CORS headers when the request's origin is the site's own host, the site's Frontend Domain, or a host listed under Allowed Origins. It compares hosts and ignores ports.

  1. A master signs in at /auth?master_user=true and opens Sites under Runtime.
  2. Click Edit on the site's row. A dialog opens on its Configuration tab.
  3. Enter the frontend's host in Frontend Domain, or click Add Origin and enter an address under Allowed Origins.
  4. Submit the form.

A preflight from an allowed origin gets a 204 with these headers. This is the answer of our local host for the origin http://localhost:3000:

HTTP/1.1 204 No Content
access-control-allow-origin: http://localhost:3000
access-control-allow-credentials: true
access-control-allow-methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
access-control-allow-headers: Accept, Authorization, Content-Type, X-Requested-With, x-no-tenant
access-control-max-age: 86400

An origin that is not on the list gets the 204 with no CORS headers, so the browser blocks the call. Request headers the API accepts are Accept, Authorization, Content-Type, X-Requested-With and x-no-tenant.

Blog, documents and pages share one shape

Each extension mounts its records under the same /api/json prefix, with the same three surfaces: readers, site admins and masters.

Mishka Blog/v1/user/mishka-blogPosts, collections, tags, bloggers, teams, approved comments. Readers can also rate, bookmark and follow once signed in.
Mishka Document/v1/user/mishka-documentWorkspaces, versions and approved pages. Lists and reads need no sign-in.
The CMS itself/v1/tenant/admin/runtimePages, layouts, components, media and redirects, for accounts with an admin:api: name.

The Blog reference Blog JSON:API lists every route and has an example that signs in, creates a post and schedules it. JSON:API routes does the same for documents. A locked record the caller cannot open is left out of lists and answers 404 when asked for by id.

The CMS also serves its own spec. /api/json/open_api and /api/json/swaggerui list every route, filter and field, and both open for an account that holds a name under admin:api. Without one they answer 403.

When to pick which

Keep it coupledMarketing site, blog, docsThe CMS draws pages, you change them in the editor and nothing deploys. This is the fastest path and the one the product is built around.
Go headlessA separate app owns the designYou already have a Next.js, Astro or mobile app, or a team that works in its own repo. The CMS holds the content and the admin.
Stay HybridYou want bothDraw the website in the CMS and let a mobile app or a script read the same posts.
Phoenix OnlyNo API wantedA site that nobody should read from outside. The API answers 403.

Next steps

Start with the Read content over the JSON:API guide, and keep Headless or coupled open while you decide. The extension pages are Mishka Blog and Mishka Document.

The Mishka CMS docs page Headless or coupled, labelled version 0.0.2, with the table of the three modes

The concept page for the three modes, in the 0.0.2 docs.

If you want to see how we ship the CMS, read what changed in Mishka CMS 0.0.2 and the first story, how we built a runtime CMS on Phoenix LiveView.

Mishka CMS is free, and a small team builds it. If you want to help us work on it full time, a founding seat gives you lifetime access, priority support and every paid extension we release.

Time left until the offer ends
119
D
08
H
13
M
48
S
Claim your seat · $250One payment, no subscription. Questions first? Contact us.

You can also sponsor our open source work, or hire our team for a Phoenix LiveView project.

Newsletter

Mishka CMS releases. Once a month.

New Mishka CMS releases, Elixir and Phoenix patterns, and case studies from the team. No spam, unsubscribe anytime.

✓ once a month✓ no spam

Questions people ask about headless Mishka CMS

In a coupled site the CMS draws the web pages itself. In a headless site a separate app draws them and reads the content from the JSON:API under /api/json. Each site picks one of three modes: Hybrid, Phoenix Only or API Only.

A master opens Sites under Runtime and sets the site's Mode to API Only, or leaves it on Hybrid to serve pages and the API together. An API Only site refuses web visits on its host, the admin dashboard included.

No. Published posts are served at /api/json/v1/user/mishka-blog/post/published with no token. Admin routes need a token from the sign-in route and a permission name that starts with admin:api:.

Use page[limit] and page[offset] for paging, with 20 records by default and 100 at most, page[count]=true for the total, sort=-published_at for newest first, filter[...] to narrow a list and fields[blog_post] to pick attributes.

Allow the origin on the site's form: enter the frontend's host in Frontend Domain, or add it under Allowed Origins. The CMS then sends CORS headers for it. It compares hosts and ignores ports.

Yes. Hybrid is the default mode. The CMS draws the pages, and other apps can still read and write through the API.

Thank you

Mishka CMS stands on Elixir, Phoenix LiveView and Ash Framework, and the JSON:API stands on the Ash packages that serve it. Thank you to everyone who builds them, and to you for reading this far.

Contributors Mishka Group Core
Shahryar Tavakkoli
LEAD
Shahryar Tavakkoli @shahryar-tavakkoli
Elixir · Phoenix · LiveView

Shahryar Tavakkoli writes about Elixir and Phoenix LiveView: Mishka Chelekom releases, GuardedStruct validation, static sites, service workers and SEO.

Comments

Sign in to comment

No comments yet.