Skip to content

· 13 min

Build a blog with Next.js and Externa in 20 minutes

Turn the official Externa Next.js starter into a blog: Articles pack, public role Read, and Public CMS API list/post pages.

Tested with Externa Core 1.2.2 · Next.js starter 1.0.4Updated October 23, 2026
Externa's items list for a blog collection next to the Public CMS API request a Next.js blog uses.

Build a blog with Next.js and Externa in 20 minutes

You've got a Next.js frontend and you want a blog behind it, without writing an admin panel yourself. In this tutorial we'll take Externa, an open-source headless CMS built on Laravel, plus its official Next.js starter, and end up with a blog index and post pages that read from Externa's Public CMS API (/api/v1).

What we'll do:

  1. Run Externa locally
  2. Create an articles collection in one click with the Articles collection pack
  3. Let anonymous visitors read it through the public role
  4. Clone the Next.js starter and check that it connects
  5. Add /blog and /blog/[slug], with covers, categories and the block-based body

Everything here matches Externa core 1.2.2 and the current externa-next-starter (v1.0.4, Next.js 15 App Router).

Prerequisites: Externa on PHP 8.4.27+ or 8.5.12+ (composer ^8.4.27; use a patched runtime), Node.js 20+ (the starter pins 24 in .nvmrc), and basic App Router knowledge. The examples use http://externa-core.test (the Laravel Herd default), so swap in your own APP_URL.


1. Run Externa

If Externa isn't running yet, follow the Quick Start. The short version for a new project:

bash
composer create-project qiick/externa-core externa-core
cd externa-core
php artisan externa:install

The installer walks you through the .env, database, seeding and the frontend build. From core 1.2.2 it preserves a valid existing APP_KEY (or generates one when missing), the default minimal profile uses the database queue/cache and needs no Redis, and you can re-run it with --force to overwrite .env or continue when APP_ENV looks like production. Seeding creates four roles: super-admin, admin, reader, and the system role public, which is what anonymous API calls use. We'll need it in a minute. The local default super admin is superadmin@example.com / password unless you overrode it before seeding. Change those credentials before you deploy anywhere shared.

2. Create the articles collection from a pack

You could build the schema field by field in the Fields builder, but Externa also ships collection packs: ready-made starter collections. On the Collections list, choose Create from pack… → Articles. (The same action is POST /collections/packs/articles and needs can-create-collections. Details: Field types: Collection packs.)

The pack creates these fields on articles:

FieldTypeNotes
titlestringtranslatable
slugstring
excerpttextareatranslatable
bodyblocksblock types section (a heading) and rich_text (a wysiwyg body)
statusselectdraft, published, archived
published_atdate
coverimage

It also needs two other collections, categories and seo, and creates them if they're missing. articles gets two many-to-one relations: category → categories and seo → seo.

The detail that matters for rendering: body is a blocks field, not a single rich-text field. Its value is an ordered array of { id, type, data } entries, and the translatable fields inside each block keep their locale map, for example { "en": "<p>…</p>" } (Field types: Blocks). We'll render it in step 6.

Now add two or three articles under Collections → Articles. Give each one a slug, a published_at date and a cover image, add a couple of body blocks, and set status to published on at least one.

Two kinds of "published". The status field from the pack is an ordinary select field on your content. It's separate from Externa's optional content versioning (Draft/Published workspaces). This tutorial filters on status. Versioning and Live Preview get their own article.

3. Give the public role read access

Admin permissions and API permissions are separate in Externa. Having can-edit-collections in the admin doesn't give anyone access to /api/v1. API access comes from a role's Collection access and Files access matrices, and anything you haven't granted is denied.

For a public blog:

  1. Go to Settings → Roles and edit public. This system role is the anonymous API caller. It can't be renamed, deleted, or assigned to users.
  2. Under Collection access, set Read to ✓ for articles and categories. Leave Create, Update and Delete at ✕.
  3. Under Files access, set Read to ✓ so cover images can be expanded and loaded. Leave Read private at ✕.
  4. Save.

Check it with curl:

bash
curl -sS 'http://externa-core.test/api/v1/collections/articles/items?page=1&per_page=5' \
  -H 'Accept: application/json'

You should get a data array plus pagination meta (current_page, last_page, per_page, total). A 403 means the role doesn't have Read on that collection.

Optional hardening: each Collection access grant can carry rules under Fields & item filter. An item_filter of status equals published on the public role's articles grant means anonymous callers never see drafts, even if your frontend forgets to filter. See Public CMS API: field-level rules and item filters.

If you'd rather not expose content anonymously, create a role such as api-website, grant it Read, and issue an API key for it under Settings → Access → API Keys. The ek_… secret is shown once.

4. Clone the Next.js starter

bash
git clone https://github.com/qiick-io/externa-next-starter.git
cd externa-next-starter
cp .env.example .env.local

Edit .env.local:

env
EXTERNA_API_URL=http://externa-core.test
# optional: leave empty to use the public role
EXTERNA_API_KEY=
EXTERNA_COLLECTION=articles
bash
npm install
npm run dev

Open http://localhost:3002. The starter gives you /, which lists the items of one collection, and /items/[id], which dumps one item's field values. Its fetch client lives in lib/externa/: externaFetch() sends Accept: application/json, adds Authorization: Bearer … only when EXTERNA_API_KEY is set, and throws an ExternaHttpError with the status code on failure.

🔐 Keep the key on the server. EXTERNA_API_KEY is only read in Server Components and server-side fetch. Never prefix it with NEXT_PUBLIC_.

If / lists your articles, the connection works. Now let's put a real blog on top, reusing the starter's client instead of writing a second one.

5. A small, typed blog layer

The docs publish TypeScript types for the Public CMS API (Public CMS API types). Item data is Record<string, unknown> because every collection has its own fields, so we narrow it for articles ourselves.

Create lib/blog.ts:

ts
// lib/blog.ts: server-only helpers for the blog, built on the starter's client.
// Response shapes: https://docs.externa.qiick.io/docs/public-cms-api-types
import { externaFetch } from '@/lib/externa/client'
import type { CollectionItem } from '@/lib/externa'

const COLLECTION = 'articles'
// Must be an enabled content locale in Settings → Project → Content languages (otherwise 422).
export const LOCALE = 'en'

type ListResponse = {
  data: CollectionItem[]
  meta: {
    current_page: number
    last_page: number
    per_page: number
    total: number
  }
}

export type ExpandedFileField = {
  id: number
  filename: string
  title: string | null
  mime_type: string | null
  width: number | null
  height: number | null
  filesize: number | null
  url: string
  transforms: Record<string, string>
}

/** One entry of a `blocks` field: `{ id, type, data }`. */
export type Block = { id: string; type: string; data: Record<string, unknown> }

/** Translatable values arrive flattened (string) or as a locale map ({ en: "…" }). */
export function text(value: unknown, locale = LOCALE): string {
  if (typeof value === 'string') return value
  if (value && typeof value === 'object' && !Array.isArray(value)) {
    const map = value as Record<string, unknown>
    const v = map[locale] ?? Object.values(map)[0]
    return typeof v === 'string' ? v : ''
  }
  return ''
}

/** With include=files an image field is an object with a url; otherwise an id, null or { access: 'denied' }. */
export function cover(item: CollectionItem): ExpandedFileField | null {
  const c = item.data.cover
  return c && typeof c === 'object' && 'url' in c
    ? (c as ExpandedFileField)
    : null
}

export function blocks(value: unknown): Block[] {
  return Array.isArray(value)
    ? value.filter(
        (b): b is Block =>
          !!b && typeof b === 'object' && 'type' in b && 'data' in b,
      )
    : []
}

function itemsPath(query: Record<string, string>): string {
  return `/api/v1/collections/${COLLECTION}/items?${new URLSearchParams(query)}`
}

export async function listPosts(page = 1): Promise<ListResponse> {
  return externaFetch<ListResponse>(
    itemsPath({
      page: String(page),
      per_page: '10',
      locale: LOCALE,
      'filter[status][_eq]': 'published',
      include: 'files,category',
    }),
  )
}

export async function getPostBySlug(
  slug: string,
): Promise<CollectionItem | null> {
  const res = await externaFetch<ListResponse>(
    itemsPath({
      per_page: '1',
      locale: LOCALE,
      'filter[slug][_eq]': slug,
      'filter[status][_eq]': 'published',
      include: 'files,category',
    }),
  )
  return res.data[0] ?? null
}

/** The items API has no sort parameter, so order the fetched page by published_at (YYYY-MM-DD). */
export function byPublishedAtDesc(
  a: CollectionItem,
  b: CollectionItem,
): number {
  return String(b.data.published_at ?? '').localeCompare(
    String(a.data.published_at ?? ''),
  )
}

What the query parameters do (all documented on the Public CMS API page):

  • filter[field][_eq]=…: list filters, with the same dialect as the admin and GraphQL. Other operators include _neq, _contains, _in, _null, _nnull, _gt/_gte/_lt/_lte.
  • include=files,category: REST responses are lean by default. Without include=files, cover is just a file id. With it, you get an object with url, width, height and transforms. A relation field name such as category hydrates the related item at depth 1, as long as the role can read the related collection.
  • locale=en: picks the locale used to flatten translatable fields. It must be an enabled content locale, otherwise you get a 422.
  • page / per_page: pagination. per_page is clamped to 1–100 and defaults to 15.

Sorting is client-side. The items endpoint has no sort parameter: the documented parameters are page, per_page, filter[...], locale, include_all_translations and include, and the API returns items newest-first by id. That's why byPublishedAtDesc sorts the fetched page by published_at. It orders one page at a time: once you have more posts than per_page, a post on page 2 can carry a later published_at than one on page 1. If that matters, enter published_at in creation order or fetch all pages before sorting.

6. Render the block body

The body field is a list of blocks. The pack defines two types, so we handle those and ignore anything we don't know yet.

Create components/blog/post-body.tsx:

tsx
// components/blog/post-body.tsx
import { blocks, LOCALE, text } from '@/lib/blog'

/** Renders the Articles pack `body`: a blocks field with `section` and `rich_text` block types. */
export function PostBody({
  value,
  locale = LOCALE,
}: {
  value: unknown
  locale?: string
}) {
  return (
    <div className="post-body">
      {blocks(value).map((block) => {
        switch (block.type) {
          case 'section':
            return <h2 key={block.id}>{text(block.data.heading, locale)}</h2>
          case 'rich_text':
            // wysiwyg HTML. Externa strips scripts and non-allowlisted tags on save;
            // add your own sanitizer here if your policy requires it.
            return (
              <div
                key={block.id}
                dangerouslySetInnerHTML={{
                  __html: text(block.data.body, locale),
                }}
              />
            )
          default:
            return null // block types you add to the field later
        }
      })}
    </div>
  )
}

And a presentational component for a whole post, components/blog/post-view.tsx. Keeping the layout separate from data fetching pays off in the Live Preview article, where the preview route renders the same component:

tsx
// components/blog/post-view.tsx
import Link from 'next/link'
import { PostBody } from '@/components/blog/post-body'

export type PostViewProps = {
  title: string
  excerpt: string
  categoryName?: string | null
  cover?: { url: string; alt: string; width?: number; height?: number } | null
  body: unknown
  locale?: string
}

/** Presentational post layout, shared by /blog/[slug] and the Live Preview route. */
export function PostView({
  title,
  excerpt,
  categoryName,
  cover,
  body,
  locale,
}: PostViewProps) {
  return (
    <article>
      <Link className="back" href="/blog">
        ← All posts
      </Link>
      {categoryName && <p className="meta">{categoryName}</p>}
      <h1>{title}</h1>
      {excerpt && <p className="lede">{excerpt}</p>}
      {cover && (
        // eslint-disable-next-line @next/next/no-img-element
        <img
          className="post-cover"
          src={cover.url}
          alt={cover.alt}
          width={cover.width}
          height={cover.height}
        />
      )}
      <PostBody value={body} locale={locale} />
    </article>
  )
}

7. The blog index: app/blog/page.tsx

tsx
// app/blog/page.tsx
import Link from 'next/link'
import { byPublishedAtDesc, cover, listPosts, text } from '@/lib/blog'

export default async function BlogIndex() {
  const { data, meta } = await listPosts(1)
  const posts = [...data].sort(byPublishedAtDesc)

  return (
    <>
      <h1>Blog</h1>
      <p className="lede">
        {meta.total} published article{meta.total === 1 ? '' : 's'}
      </p>
      <ul className="item-list">
        {posts.map((post) => {
          const img = cover(post)
          return (
            <li key={post.id}>
              <Link href={`/blog/${encodeURIComponent(text(post.data.slug))}`}>
                {img && (
                  // eslint-disable-next-line @next/next/no-img-element
                  <img
                    className="post-cover"
                    src={img.transforms.thumb ?? img.url}
                    alt={img.title ?? ''}
                  />
                )}
                {text(post.data.title)}
                <span className="meta">
                  {String(post.data.published_at ?? '')}
                </span>
              </Link>
            </li>
          )
        })}
      </ul>
    </>
  )
}

The image URL looks like …/api/v1/files/{id}/content, a gated endpoint served by Externa (that's why the public role needs Files → Read). If you define image transform presets in Settings → Project, they show up in transforms; the index uses a thumb preset when it exists and the original file otherwise.

The starter's global stylesheet already styles .lede, .item-list, .meta and .back. Add two small rules to app/globals.css for covers and the body:

css
/* blog */
.post-cover {
  display: block;
  max-width: 100%;
  height: auto;
  border-radius: 8px;
  margin: 0 0 0.75rem;
}

.post-body {
  line-height: 1.7;
}

8. The post page: app/blog/[slug]/page.tsx

tsx
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { PostView } from '@/components/blog/post-view'
import { cover, getPostBySlug, text } from '@/lib/blog'

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

export default async function PostPage({ params }: Props) {
  const { slug } = await params
  const post = await getPostBySlug(decodeURIComponent(slug))
  if (!post) notFound()

  const img = cover(post)
  // With include=category this is a nested item resource; without it (or without Read on categories) it's the raw id.
  const category = post.data.category
  const categoryName =
    category && typeof category === 'object' && 'data' in category
      ? text((category as { data: Record<string, unknown> }).data.name)
      : null

  return (
    <PostView
      title={text(post.data.title)}
      excerpt={text(post.data.excerpt)}
      categoryName={categoryName}
      cover={
        img && {
          url: img.url,
          alt: img.title ?? '',
          width: img.width ?? undefined,
          height: img.height ?? undefined,
        }
      }
      body={post.data.body}
    />
  )
}

What you'll get back:

  • With include=category, data.category becomes a nested item resource ({ id, collection_id, data, … }). Without it, or when the role can't read categories, it stays a raw id. The page handles both.
  • The categories pack marks name as translatable. Top-level translatable fields come back flattened for the requested locale, so text() usually just returns the string. Inside body, the block fields keep their locale map, and text() picks the right key.
  • File fields the role can't read come back as { "id": N, "access": "denied" } (public file, no Files → Read) or null (private file). cover() only accepts objects that have a url.

Visit http://localhost:3002/blog, click a post, and you're reading Externa content in Next.js.

9. Before you ship

  • Use an API key on the server once you set Allowed origins. With Settings → Project → Public API → Allowed origins filled in, requests without an Origin header get 403 Origin required or API key. unless they send a valid ek_… key. Server-side rendering is exactly that kind of request. See Origin allowlist.
  • Check your images with the allowlist on. The browser loads covers straight from /api/v1/files/..., which sits behind the same origin gate, and a plain <img> request usually doesn't carry an Origin header. Test it before launch; adding crossOrigin="anonymous" to the <img> makes the browser send one.
  • Keep public grants minimal. Read only, no Read private, and ideally an item_filter on status.
  • Choose a caching strategy. The starter's externaFetch() uses cache: 'no-store', which is right while you iterate. For production, consider Next.js revalidation plus Externa's outbound webhooks (for example on item.updated) to rebuild or revalidate when content changes.
  • Prefer GraphQL? The same collection guards apply at /api/graphql. See GraphQL.

Recap

You now have:

  • An articles collection (plus categories and seo) created from a pack
  • Anonymous, read-only API access controlled by the public role
  • A Next.js App Router blog with an index and slug-based post pages, built on the starter's own client
  • Expanded covers and categories through include, and a renderer for the blocks body

Next up: Live Preview in practice, so editors can open a draft in this frontend before it goes live.


Use AI while you follow along

Paste the Wire the Next.js starter into a blog prompt into Cursor or Claude. It tells the assistant to read https://docs.externa.qiick.io/llms.txt and the pages above before it writes any code, so it sticks to documented APIs.

Docs used in this article

AI prompt for this guide

  • Wire the Next.js starter into a blog

    Turn qiick-io/externa-next-starter into a blog (list + slug pages) on the Articles collection pack, using the Public CMS API.

    • Starter
    • Next.js
    • Public CMS API
    You are helping me with Externa, an open-source headless CMS for Laravel (documented core version 1.2.2).
    
    BEFORE WRITING ANY CODE OR ADVICE:
    1. Read https://docs.externa.qiick.io/llms.txt first. It lists every docs page as Markdown.
    2. Then read these pages (fetch the .md versions):
       - https://docs.externa.qiick.io/docs/headless-starter.md
       - https://docs.externa.qiick.io/docs/public-cms-api.md
       - https://docs.externa.qiick.io/docs/public-cms-api-types.md
       - https://docs.externa.qiick.io/docs/field-types.md
       - https://docs.externa.qiick.io/docs/roles-and-permissions.md
       - https://docs.externa.qiick.io/docs/quick-start.md
       - https://docs.externa.qiick.io/docs/ai-coding-tools.md
    3. Optional: pull docs via Context7 MCP using library IDs /qiick-io/externa-core and/or /llmstxt/externa_qiick_io_llms_txt.
    4. If you cannot fetch URLs, tell me and ask me to paste those pages. Do not guess.
    
    RULES:
    - Use ONLY endpoints, parameters, field types, permission names, env vars and UI paths that appear in those docs (or in the starter's code, if I paste it). If something is not documented, say "not documented — check the docs" instead of inventing it.
    - Cite the docs page (URL) for each important step.
    - Clearly separate Externa behavior from framework/host behavior (Next.js, Nuxt, Vercel, Docker, etc.).
    - Never ask me to paste secrets (API keys ek_…, APP_KEY, webhook secrets) into this chat; use placeholders.
    - Before finishing, list any assumptions you made and anything I should double-check against my installed version.
    
    GOAL: Help me turn the official Next.js starter (qiick-io/externa-next-starter, Next.js 15 App Router) into a blog with a /blog index and /blog/[slug] pages, reading an `articles` collection from Externa's Public CMS API (/api/v1).
    
    STEPS TO HELP WITH:
    1. Confirm from the docs how to create the `articles` collection with the Articles collection pack (Collections → Create from pack…) and which fields and relations it creates. Note that `body` is a `blocks` field (block types `section` and `rich_text`), so its value is an array of { id, type, data } and nested translatable fields keep their locale map.
    2. Tell me exactly which grants the `public` role (or an API-key role) needs: Collection access → Read on `articles` and `categories`, Files access → Read for covers.
    3. Write a server-only blog helper that reuses the starter's `externaFetch()` from lib/externa/client.ts and ONLY documented query params (page, per_page, filter[field][_eq], include, locale, include_all_translations). Use `include=files,category` to expand the cover and category.
    4. The items endpoint has no sort parameter (items come back newest-first by id), so sort by `published_at` in my code and explain the per-page limitation.
    5. Generate the index and slug pages plus a renderer for the blocks body. Keep EXTERNA_API_KEY server-side (never NEXT_PUBLIC_).
    6. Remind me that, once Allowed origins is set, server-side requests need an API key, and that browser image requests to /api/v1/files are behind the same origin gate.
    
    MY CONTEXT (fill in):
    - Externa URL (local/staging): <e.g. http://externa-core.test>
    - Frontend / stack: <e.g. Next.js 15 App Router on Vercel>
    - What I already have: <paste relevant schema, files, or errors — no secrets>

    These prompts tell your assistant to read the official docs first (llms.txt and Context7 IDs /qiick-io/externa-core and /llmstxt/externa_qiick_io_llms_txt). Always verify generated code, and never paste API keys, APP_KEY, or webhook secrets into a chat.