Integrations

Use gluso with Next.js

gluso is plain HTTP + JSON, so Next.js needs no SDK — a small fetch helper and your App Router pages are done. Examples assume a collection whose API name is posts with a slug field — swap in your own collection's API name (shown in the entry editor's API tab).

1. Environment variables

Create an API key with the content.read scope (Settings → Space → Developers) and keep it server-side — no NEXT_PUBLIC_ prefix:

# .env.local
GLUSO_API_KEY=glk_…

2. A fetch helper

// lib/gluso.ts
const BASE = 'https://glu.so'

export async function gluso<T>(
  path: string,
  query: Record<string, string> = {},
): Promise<T> {
  const url = new URL('/api/v1/public/content/' + path, BASE)
  for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v)
  const res = await fetch(url, {
    headers: { Authorization: 'Bearer ' + process.env.GLUSO_API_KEY },
    next: { revalidate: 60 },
  })
  if (!res.ok) throw new Error('gluso ' + res.status + ': ' + url.pathname)
  return res.json()
}

next.revalidate gives you ISR — pages re-fetch at most once a minute. Use cache: 'no-store' instead for always-fresh data.

3. List & detail pages

// app/blog/page.tsx
import { gluso } from '@/lib/gluso'

export default async function BlogIndex() {
  const { list } = await gluso<{ list: any[] }>('posts', { perPage: '20' })
  return (
    <ul>
      {list.map((post) => (
        <li key={post.id}>
          <a href={'/blog/' + post.slug}>{post.data.title}</a>
        </li>
      ))}
    </ul>
  )
}

Rich-text fields come back as HTML strings. Sanitize before rendering unless every entry author is trusted — a tiny helper (works in server components):

// lib/sanitize.ts
import DOMPurify from 'isomorphic-dompurify' // npm i isomorphic-dompurify

export function sanitize(html: string): string {
  return DOMPurify.sanitize(html ?? '')
}

The detail page fetches by slug and renders the sanitized body:

// app/blog/[slug]/page.tsx
import { gluso } from '@/lib/gluso'
import { sanitize } from '@/lib/sanitize'

export async function generateStaticParams() {
  const { list } = await gluso<{ list: any[] }>('posts')
  return list.map((post) => ({ slug: post.slug }))
}

export default async function BlogPost(props: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await props.params
  const { doc } = await gluso<{ doc: any }>('posts/' + slug, { depth: '1' })
  return (
    <article>
      <h1>{doc.data.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: sanitize(doc.data.body) }} />
    </article>
  )
}
Field values are keyed by API name. If yours contains a hyphen (e.g. related-posts), dot access won't parse — use bracket access: data['related-posts'].

4. Personalization (optional)

Decide the visitor's variants in middleware or the page (from geo headers, device, UTM params, cookies) and pass them along — gluso merges the overrides server-side:

// e.g. derive variants from the request, then:
const { doc } = await gluso('posts/' + slug, {
  variants: ['mobile', 'returning'].join(','),
})
// doc.data is already merged for that audience

Careful with ISR: personalized responses vary per visitor, so either resolve variants in middleware and cache per combination, or fetch the raw layer once and resolve client-side — see API → Personalization.

5. Live preview (optional)

Point the collection's preview baseUrl at your Next.js site (e.g. pathTemplate: /blog/{slug}) in its settings. The editor then loads your page in an iframe and streams draft edits over postMessage — the visual editing guide covers the protocol and the usePreview hook.

App Router pages are server components, and hooks only run on the client — so pass the server-fetched data into a small 'use client' component that calls usePreview. It renders published data normally, and swaps to live draft data (variant-resolved) while the editor drives it:

// app/blog/[slug]/PreviewablePost.tsx
'use client'
import { usePreview } from '@/lib/usePreview'
import { sanitize } from '@/lib/sanitize'

export default function PreviewablePost({ published }: { published: any }) {
  const data = usePreview(published) // published data, or live draft in preview
  return (
    <article>
      <h1>{data.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: sanitize(data.body) }} />
    </article>
  )
}

Then the detail page just fetches and hands off to it:

// app/blog/[slug]/page.tsx
import { gluso } from '@/lib/gluso'
import PreviewablePost from './PreviewablePost'

export default async function BlogPost(props: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await props.params
  const { doc } = await gluso<{ doc: any }>('posts/' + slug, { depth: '1' })
  return <PreviewablePost published={doc.data} />
}

Enable it from the entry editor: ⋮ (More actions) → Visual editing.

6. Component & section pages

Landing pages are often built from components — a hero, stacked content sections, a sidebar panel. In gluso these are component fields: a single component, or an array of them. Each value is { componentTypeId, data }, where data is that component's fields keyed by API name:

// GET /pages/home?depth=1 → doc.data
{
  "title": "Home",
  "heroSection": {                       // a single component field
    "componentTypeId": "6a3847…",
    "data": { "heading": "Build fast", "ctaUrl": "/signup" }
  },
  "sections": [                          // an array of component fields
    { "componentTypeId": "6a3847…", "data": { "heading": "Model any content" } },
    { "componentTypeId": "6a3847…", "data": { "heading": "Edit visually" } }
  ],
  "featuresPanel": { "componentTypeId": "6a38af…", "data": { "features": [] } }
}

Render them with a registry that maps each componentTypeId to a React component (find the id under the component type's settings, or the entry's API tab):

// components/sections/registry.tsx
import Hero from './Hero'
import FeaturesPanel from './FeaturesPanel'

const REGISTRY: Record<string, React.ComponentType<{ data: any }>> = {
  '6a3847…': Hero,          // your Hero component type id
  '6a38af…': FeaturesPanel, // your Features Panel component type id
}

export function Section({ block }: { block?: { componentTypeId: string; data: any } }) {
  if (!block) return null
  const Cmp = REGISTRY[block.componentTypeId]
  return Cmp ? <Cmp data={block.data} /> : null // unknown type → skip
}

Then a page composes the fields — a single hero, an array of stacked sections, a sidebar panel — and live editing just works: the same usePreview hook streams the whole page's draft data, so editing any nested component field re-renders its section instantly.

// app/[slug]/PreviewablePage.tsx
'use client'
import { usePreview } from '@/lib/usePreview'
import { Section } from '@/components/sections/registry'

export default function PreviewablePage({ published }: { published: any }) {
  const data = usePreview(published)
  if (!data) return null
  return (
    <div className="layout">
      <Section block={data.heroSection} />
      <div className="grid">
        <main>
          {(data.sections ?? []).map((block: any, i: number) => (
            <Section key={i} block={block} />
          ))}
        </main>
        <aside><Section block={data.featuresPanel} /></aside>
      </div>
    </div>
  )
}