devigo
v0.6.1
Published
Official JavaScript SDK for Devigo Studio. The whitelabel headless CMS built for every site.
Readme
Devigo JavaScript SDK
Official JavaScript SDK for the Devigo CMS. TypeScript-first, zero dependencies, ESM only.
Installation
npm install devigoRequires Node.js 18 or later (uses the native fetch API).
Quick Start
import { createClient } from 'devigo'
const devigo = createClient({
token: process.env.DEVIGO_API_TOKEN,
baseUrl: process.env.DEVIGO_API_DOMAIN, // https://api.devigo.studio
siteId: process.env.DEVIGO_SITE_ID,
environment: process.env.ENVIRONMENT, // 'local' | 'staging' | 'live'
})
const home = await devigo.pages.get('homepage')
const posts = await devigo.posts('blog').list({ limit: 10 })
const header = await devigo.menus.get('header')Environments
environment is required — there is no server-side default, and the API
rejects values it does not recognise. Devigo sites use local, staging and
live.
Caching
GET responses are cached per client for its lifetime, so a static build that
renders 50 pages fetches each menu once rather than 50 times. Pass
cache: false for a long-lived SSR client that must always read fresh content,
or call devigo.clearCache() to drop what's cached.
Languages
A site can be written in more than one language. Each is a code — en, pl,
pt-BR — and a translated page is a page of its own, with its own slug.
await devigo.locales() // → { default_locale: 'en', locales: ['en', 'pl'] }
const pl = devigo.locale('pl') // the same client, reading Polish
await pl.pages.get('zajezdnie') // → Page, with `locale` and `translations`
await pl.menus.items('header') // links already under /pl/page.translations and entry.translations list every language the page is
in, itself included, as { locale, slug } — what hreflang links and a
language switcher need. A language with no menu of its own gets the default
language's, with its links pointed at translated pages. Settings in a language
fall back, field by field, to the default language's.
The convention everywhere is the default language at the root and every other
under its code; localePath('/depots', 'pl', 'en') gives /pl/depots.
Astro
// astro.config.mjs
import { defineConfig } from 'astro/config'
import { devigoIntegration } from 'devigo/astro'
export default defineConfig({
integrations: [devigoIntegration({ postTypes: ['blog'] })],
})Credentials are read from DEVIGO_API_TOKEN, DEVIGO_API_DOMAIN,
DEVIGO_SITE_ID and ENVIRONMENT in your .env, so no loadEnv wiring is
needed. Pass them explicitly to override. In dev the integration polls for
content changes and restarts the server so a CMS edit shows up on refresh.
Languages in Astro
The integration reads the site's languages when the dev server starts and at
build, and routes every one: pages at /pl/{slug}, posts at
/pl/{type}/{slug}, listings at /pl/{type}. Only what has been translated
is routed — an untranslated page has no /pl/ address. Layouts receive the
page's locale alongside page or entry, and devigo:options exports
defaultLocale and locales. A site with one language gets exactly the
routes it always had. Adding a language needs a dev server restart, as it
changes which routes exist. Pass locales: false to route the default only.
Favicon
The Favicon set in Devigo Studio is downloaded at dev start and at build,
and written into your project's public/:
| Setting | Written | Linked in <head> |
| --- | --- | --- |
| an .svg | favicon.svg, favicon.ico | both |
| an .ico | favicon.ico | favicon.ico |
| any other image | favicon.ico (converted) | favicon.ico |
| nothing set | — | whatever your project ships |
Real files rather than a CDN link, so /favicon.ico is your icon for
everything that asks for it by name — browsers request it whether or not a
<link> says to — and so the last good icon survives a build with no network.
A raster setting never writes favicon.svg, because a file that promises
vector would then hold something else and browsers prefer it.
In dev the icon follows the setting without a restart — whether it is changed in Studio or in the on-page editor's Styles tab.
Render the tags with the component — it is handed what the integration wrote, so nothing has to re-derive which files exist:
---
import Favicon from 'devigo/astro/components/Favicon.astro';
---
<head>
<Favicon />
</head>The files replaced are set aside under .astro/, and put back if the setting
is later cleared. Converting to .ico uses sharp, which Astro's image
pipeline already installs; without it the .ico is left alone and a warning
says so. Pass favicon: false to manage the files yourself.
On-page editing (experimental)
devigoIntegration({ editor: true })Puts an editing bar on the site in astro dev. Turn editing on and every CMS
block outlines as you pass over it; click one and its text becomes editable
where it sits — click into a heading and type, exactly as you would in a
document. Everything that isn't free text — toggles, radios, selects, images —
opens in a panel alongside, because "does this button open in a new tab" is a
choice, not something you type. Save writes to the CMS and the page reloads with
the change. Menu items are editable too, by label.
Text is matched to the page by its value, so a component that renders a field verbatim gets inline editing for free, with no changes to it. One that transforms, truncates or puts the value in an attribute simply keeps that field in the panel; the bar says how many of each.
Repeaters list their items collapsed, one line each, and clicking a line opens
that item on its own screen with a breadcrumb back. While a repeater is open the
page shows a dashed + below where the next item would render. Clicking it adds
the item and saves, so the empty one appears on the page immediately and you
land in it ready to type — an item that only existed in the panel would leave
you wondering whether the click had done anything.
Settings is a button on the bar rather than something you click on the page, since a logo or a set of social links has no single element to point at. It opens the same panel over the site's own settings fields.
Rich text fields get the same toolbar Devigo Studio gives them — headings, bold,
italic, lists, links, images from your media library — over a </> button for
editing the HTML directly. Pasted content is reduced to the same tags the server
keeps, so formatting from Google Docs or Word survives and everything else is
dropped before it can be saved.
The panel is built from each component's own field definitions, so it works on
your components with no changes to them. Needs a second credential in your
.env —
DEVIGO_EDIT_TOKEN=dv_edit_…— generated per site under Integrations → On-Page Editing in Devigo Studio. The build token will not work: it cannot write, and it is deliberately rejected, so a token that already sits in every checkout and CI job never becomes the one that can rewrite a site.
The token stays on the dev server. The browser talks to /__devigo, the dev
server talks to Studio, and nothing that can change content is ever readable
from the page. Requests from another origin are refused, and the site and
environment are pinned from your own config rather than taken from the request
— so edits land in whatever ENVIRONMENT this project reads, and editing what
you are looking at changes exactly that and nothing else.
None of it exists in a build. astro build never registers the proxy, and the
edit token is not read.
curl http://localhost:4321/__devigo/status
# {"editing":true,"siteId":"…","environment":"local","baseUrl":"…"}Being experimental, it has edges worth knowing:
| | |
|---|---|
| Blocks are found by URL | The page is looked up by the path it is served at, which is what the injected page route does. A project routing CMS pages itself may not line up, and the bar says so rather than guessing. |
| The panel covers the right of the page | Close it (Esc) to reach a block underneath. |
| Some field types stay in Studio | Flexible content, conditional fields and form pickers carry structure the panel cannot present honestly. |
| Tables are preserved, not built | A rich text field keeps any table already in it, but building one is a six-tool job that wants a full-width screen. |
| Menu items edit their label | Where a link points is not recoverable from the rendered page — only the resolved href is — so that stays in Studio. |
API Reference
Pages
| Method | Returns |
|---|---|
| pages.list() | PageSummary[] — no sections or SEO fields |
| pages.get(slug) | Page — full page with sections |
| pages.all(options?) | Page[] — every page, fully hydrated |
| pages.homepage() | Page \| null — the page flagged is_homepage |
pages.all() is what getStaticPaths usually wants. The detail endpoint does
not return name, so all() carries it over from the list response — without
it, a page with no title_tag has nothing to fall back on for its title.
const pages = await devigo.pages.all({ exclude: ['404-page-not-found'] })SEO fields are flat on the page: title_tag and meta_description.
Posts
devigo.posts('blog').list({ limit: 10, offset: 0 }) // → { data, total }
devigo.posts('blog').get('my-post') // → PostEntry
devigo.posts('blog').all() // → PostEntry[], all pagesEntry fields are on field_values; title_tag, meta_description and
schema sit alongside it.
Menus
devigo.menus.list() // → MenuSummary[]
devigo.menus.get('header') // → Menu, with a nested `items` treeSettings
const settings = await devigo.settings.get() // → { header_logo, company_name, ... }Forms
const form = await devigo.forms.get('basic-enquiry-form') // uuid or slug
await devigo.forms.submit(form, { email: '[email protected]', message: 'Hello!' })forms.list() returns every form's uuid, name and slug.
Submission has three rules the SDK handles for you: values are wrapped in a
fields object, select fields are coerced to arrays, and untouched honeypot
fields are dropped. Use buildSubmitPayload(form, values) if you are posting
by hand.
The submit endpoint is public and CORS-open, so passing a Form you already
fetched lets you submit from the browser with no credentials. Passing a uuid
or slug instead costs an authenticated round-trip to read the definition first,
which means exposing your API token to the client.
Analytics
devigo.analytics.consent(true) // persists to localStorage
devigo.analytics.autoTrack() // tracks load + client-side navigationConsent is stored per site under devigo_consent_{siteId}. Tracking no-ops
until consent is granted, and on the server.
The track endpoint is authenticated, so browser tracking ships your API token to the client — where it also grants read access to draft content. Weigh that deliberately.
Helpers
import { internalHref, componentHandle, resolveComponent, flattenComponents } from 'devigo'| Helper | Purpose |
|---|---|
| internalHref(url) | Normalise a CMS URL to its canonical trailing-slash form. CMS links arrive without one and would otherwise 301 on click. |
| componentHandle(name) | "Content & Image" → "ContentAndImage" |
| resolveComponent(registry, name) | Look a CMS component up in a registry of your own components |
| flattenComponents(page) | Flatten the section → row → column → component tree in document order |
Components are identified by the display name typed into the CMS, which is
editable content rather than a stable id. Match through resolveComponent()
rather than on the raw string, so renames behave consistently everywhere.
import Hero from './Hero.astro'
import ContentAndImage from './ContentAndImage.astro'
const Renderer = resolveComponent({ Hero, ContentAndImage }, block.component)Error Handling
import { createClient, DevigoNotFoundError, DevigoAuthError } from 'devigo'
try {
const page = await devigo.pages.get('missing-page')
} catch (err) {
if (err instanceof DevigoNotFoundError) { /* 404 */ }
if (err instanceof DevigoAuthError) { /* 401/403 */ }
}| Error Class | Status | Description |
|---|---|---|
| DevigoApiError | any | Base class for all API errors |
| DevigoNotFoundError | 404 | Resource not found |
| DevigoAuthError | 401/403 | Invalid or expired token |
Migrating from 0.1.x
0.2.0 corrects the types to match what the API actually returns. Most sites need no code changes — the runtime shapes were always these — but TypeScript will now flag code written against the old declarations.
| Change | Action |
|---|---|
| environment is required | Set it; production was never valid |
| posts(type).get() returned undefined fields | Now returns the entry — previously broken, so any workaround can go |
| forms.submit() posted to a 404 | Now works; takes a Form or an id |
| Page.meta.title / .description | Use page.title_tag / page.meta_description |
| Component.type / .field_values | Use component.component / .props |
| Section.id, Row.id, Column.id, Page.id | Removed — the API returns no ids |
| Form.id: number | Form.uuid: string |
| media resource | Removed — the endpoint does not exist |
| globals resource | Replaced by settings |
pages.list() now returns PageSummary[] rather than Page[], which is what
it always returned at runtime. Use pages.all() where you need full pages.
License
UNLICENSED
