Integrations

Use gluso with Nuxt

Keep the API key in server-only runtime config, proxy gluso through a Nitro route, and consume it with useFetch. Examples assume a collection whose API name is posts with a slug field — swap in your own collection's API name.

1. Runtime config

Create a content.read key in Settings → Space → Developers. Non-public runtime config never reaches the browser:

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    glusoApiKey: '', // NUXT_GLUSO_API_KEY
  },
})

2. A server proxy route

A catch-all Nitro route forwards content reads and attaches the key server-side — pages and components never see it:

// server/api/content/[...path].get.ts
export default defineEventHandler((event) => {
  const { glusoApiKey } = useRuntimeConfig()
  const path = getRouterParam(event, 'path') || ''
  // Allowlist collection/slug segments so a caller can't traverse out of the
  // content prefix and reach other endpoints with the server-side key.
  if (!/^[a-zA-Z0-9_-]+(\/[a-zA-Z0-9_-]+)*$/.test(path)) {
    throw createError({ statusCode: 400, statusMessage: 'Invalid content path' })
  }
  return $fetch('/api/v1/public/content/' + path, {
    baseURL: 'https://glu.so',
    query: getQuery(event),
    headers: { Authorization: 'Bearer ' + glusoApiKey },
  })
})

3. List & detail pages

<!-- pages/blog/index.vue -->
<script setup>
const { data } = await useFetch('/api/content/posts', {
  query: { perPage: 20 },
})
</script>

<template>
  <ul>
    <li v-for="post in data?.list" :key="post.id">
      <NuxtLink :to="'/blog/' + post.slug">{{ post.data.title }}</NuxtLink>
    </li>
  </ul>
</template>

Rich-text fields are HTML. v-html renders on the server and client, so sanitize with an isomorphic helper unless every author is trusted:

// plugins/sanitize.ts — npm i isomorphic-dompurify
import DOMPurify from 'isomorphic-dompurify'
export default defineNuxtPlugin(() => ({
  provide: { sanitize: (html) => DOMPurify.sanitize(html ?? '') },
}))
<!-- pages/blog/[slug].vue -->
<script setup>
const route = useRoute()
const { $sanitize } = useNuxtApp()
const { data } = await useFetch(
  '/api/content/posts/' + route.params.slug,
  { query: { depth: 1 } },
)
</script>

<template>
  <article v-if="data?.doc">
    <h1>{{ data.doc.data.title }}</h1>
    <div v-html="$sanitize(data.doc.data.body)" />
  </article>
</template>

useFetch SSRs the request and hydrates without a second fetch; add routeRules caching (SWR/ISR) on top for CDN-friendly pages. Field values under data are keyed by API name — hyphenated names need bracket access (data['related-posts']).

4. Personalization (optional)

Derive the visitor's variants in the proxy route (device from user-agent, UTM from the query, segments from cookies) and forward them — the response comes back merged:

// in the proxy route, before $fetch:
const variants = []
if (/mobile/i.test(getHeader(event, 'user-agent') || '')) variants.push('mobile')
if (getCookie(event, 'returning')) variants.push('returning')

return $fetch('/api/v1/public/content/' + path, {
  baseURL: 'https://glu.so',
  query: { ...getQuery(event), variants: variants.join(',') },
  headers: { Authorization: 'Bearer ' + glusoApiKey },
})

Personalized responses vary per visitor — skip shared-cache routeRules on those routes, or resolve client-side from the raw layer instead. See API → Personalization.

5. Live preview (optional)

Set the collection's preview baseUrl to your Nuxt site (path template /blog/{slug}) in its settings, then enable it from the entry editor's ⋮ (More actions) → Visual editing.

In the detail page, register the bridge listener in onMounted and let a ref override the fetched data while previewing — the template renders from a computed that prefers the draft:

<!-- pages/blog/[slug].vue -->
<script setup>
const route = useRoute()
const { $sanitize } = useNuxtApp()
const { data } = await useFetch(
  '/api/content/posts/' + route.params.slug,
  { query: { depth: 1 } },
)

const GLUSO_ORIGIN = 'https://your-gluso-app.com'
const preview = ref(null)
const view = computed(() => preview.value ?? data.value?.doc?.data ?? null)

onMounted(() => {
  if (!new URLSearchParams(location.search).has('preview')) return
  const onMessage = (event) => {
    if (event.origin !== GLUSO_ORIGIN) return
    if (event.data?.type === 'gluso:preview') preview.value = event.data.data
  }
  window.addEventListener('message', onMessage)
  window.parent.postMessage({ type: 'gluso:preview-ready' }, GLUSO_ORIGIN)
  onUnmounted(() => window.removeEventListener('message', onMessage))
})
</script>

<template>
  <article v-if="view">
    <h1>{{ view.title }}</h1>
    <div v-html="$sanitize(view.body)" />
  </article>
</template>

Variant-resolved data arrives the same way — flipping personalization in the editor re-renders the preview. Full protocol in the visual editing guide.