@einblick/sdk
v0.7.12
Published
Typed client and code generator for the Einblick public API.
Readme
@einblick/sdk
Typed client and code generator for the Einblick public API.
Framework support:
@einblick/sdk: framework-agnostic typed read client, asset URL helpers, and code generator@einblick/sdk/react: React-based in-page editing provider, the<EinblickImage>asset component, and login runtime@einblick/sdk/next: Next.js App Router helpers — cache-tag builder, revalidation route handler, and the<EinblickNextEditBoot>provider
The raw HTTP API is documented as OpenAPI 3.1 at /api/v1/schema and returns
JSON:API documents for resource requests. The SDK normalizes that into ergonomic
typed objects for application code.
Install
npm install @einblick/sdkGenerate types
Set your API key in the consuming app:
EINBLICK_API_KEY=api_...Then generate the typed schema file:
npx @einblick/sdk generate --output app/lib/einblick.generated.tsThe SDK defaults to the hosted Einblick API URL.
Use it
import { createGeneratedEinblickClient } from '@/app/lib/einblick.generated'
const einblick = createGeneratedEinblickClient()
const posts = await einblick.request('posts', { limit: 10 })
const post = await einblick.request('posts', { slug: 'das-atelier' })Regenerate the file whenever the allowed resource fields change.
Modeling editable CMS content
Prefer first-class CMS field types for anything an editor is expected to
change from the external site. Short labels belong in string, longer prose in
text or markdown, media in image/images/file/files, and repeatable
items in collections connected by relations. Avoid putting editor-facing copy,
CTA labels, or prose segments inside json; the hosted drawer can edit JSON,
but it is hard for non-technical editors and field-level inline editing cannot
target individual JSON properties.
When importing or seeding CMS media, store the file in the folder for the CMS
record that owns the image/file field, or in a collection folder allowed by that
field's existing-file-selection policy. Public API asset resolution is scoped
to the resource, field, and allowed folder roots. Reusing a file ID from another
record can make the SDK/public API return null for that media field even
though the file exists.
React in-page editing
The current in-page editing runtime ships from the React subpath:
import { EinblickEditProvider, EinblickLoginButton } from '@einblick/sdk/react'This layer is designed for React-based hosts such as Next.js, Remix, or Astro
projects that render React islands. The core @einblick/sdk client itself does
not depend on React.
The in-page editing UI auto-detects en vs de from the page language and
falls back to English. You can override it explicitly:
<EinblickEditProvider siteKey='site_...' locale='de'>
...
</EinblickEditProvider>Authenticated edit mode renders the fixed Einblick control bar by default. The bar is SDK-owned chrome; heavier CMS collection and record editing panels stay hosted on the Einblick app origin. If a site needs the previous launcher or custom chrome, configure it explicitly:
<EinblickEditProvider siteKey='site_...' chrome='badge'>
...
</EinblickEditProvider>The SDK chrome uses Einblick orange by default. For host sites where that color
does not stand out enough, pass any valid CSS color as accentColor; it
replaces the orange hover outlines, quick edit buttons, badge chrome, and bottom
bar. null, an empty string, or an omitted value keeps the default.
<EinblickEditProvider siteKey='site_...' accentColor='#2563eb'>
...
</EinblickEditProvider>EinblickNextEditBoot forwards the same prop and also reads
NEXT_PUBLIC_EINBLICK_ACCENT_COLOR when the prop is omitted.
The bar sets --einblick-editor-bottom-offset and
data-einblick-editor-bar-active on <html>. By default it also reserves body
scroll space so page content is not hidden behind the bar. Host sites with their
own fixed bottom UI can opt in to moving that UI with CSS:
.fixed-bottom-widget {
bottom: calc(
var(--existing-bottom, 0px) + var(--einblick-editor-bottom-offset, 0px)
);
}Set reserveBottomSpace={false} if the host site wants full control over bottom
spacing.
The default bar shows a single Collections button. It opens a bottom sheet with all resources available to the external site session: CMS collections are grouped under an expandable CMS item, and supported native resources appear next to that group. Prefer this general entry point over duplicating resource buttons in the host site.
Next.js best practices
This section covers the recommended setup for Next.js App Router projects that
read from the Einblick CMS and use the in-page editor. The pattern is the same
one used in production by schaum, lucameusburger, janmeusburger, z57,
and klara-puermayer.
1. Server-side data fetching with cache tags
Fetch CMS data in Server Components (or Server Actions) and wrap the readers in
cache() so that multiple components in the same request share the same work.
Pass next.tags on the underlying fetch so they can be revalidated on demand.
Use the same tag helper in the revalidation route; Einblick posts the changed
resourceSlug for in-page saves and for direct CMS edits when the external site
has a revalidation endpoint configured.
// app/lib/einblick-cache.ts
export const EINBLICK_CMS_TAGS = {
all: 'einblick-cms',
projects: 'einblick-cms:projects',
siteSettings: 'einblick-cms:site-settings',
} as const
export function getEinblickCmsTags(resourceSlug?: string | null): string[] {
switch (resourceSlug) {
case 'projects':
return [EINBLICK_CMS_TAGS.all, EINBLICK_CMS_TAGS.projects]
case 'site-settings':
return [EINBLICK_CMS_TAGS.all, EINBLICK_CMS_TAGS.siteSettings]
default:
return [EINBLICK_CMS_TAGS.all]
}
}// app/lib/cms.ts
import 'server-only'
import { cache } from 'react'
import { createGeneratedEinblickClient } from './einblick.generated'
import { getEinblickCmsTags } from './einblick-cache'
const REVALIDATE_SECONDS = 60
const getRevalidatedFetch = (tags: string[]) => ({
next: { revalidate: REVALIDATE_SECONDS, tags },
})
const client = createGeneratedEinblickClient()
export const getProjects = cache(async () =>
client.request('projects', {
limit: 100,
fetch: getRevalidatedFetch(getEinblickCmsTags('projects')),
}),
)Using 'server-only' prevents the module from being imported by client
components by accident and leaking your API key. Using the narrowest tag that
still covers the resource keeps the invalidation scope small.
2. The revalidate route
Expose a POST route that the edit provider pings after each save. Return fast — the route's only job is to punch a hole in the Next.js cache.
// app/api/einblick/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import { NextResponse } from 'next/server'
import { getEinblickCmsTags } from '@/lib/einblick-cache'
export async function POST(request: Request) {
let resourceSlug: string | undefined
try {
const body = (await request.json()) as { resourceSlug?: string }
resourceSlug = body.resourceSlug
} catch {}
for (const tag of getEinblickCmsTags(resourceSlug)) {
revalidateTag(tag, { expire: 0 })
}
revalidatePath('/', 'layout')
return NextResponse.json({ ok: true })
}Use both layers for statically rendered App Router sites: revalidateTag
expires the CMS fetch/data cache, and revalidatePath expires the prerendered
route cache that Vercel may still serve. For route handlers and webhooks, use
{ expire: 0 } so the next request blocks for fresh data. profile: "max" is
stale-while-revalidate and can make router.refresh() show the old data once
after a save.
3. The edit provider with soft refresh
Mount <EinblickEditProvider> once at the root of your app. On save, revalidate
the relevant tag then call router.refresh() — this is a Next.js-specific
soft re-render that re-runs server components with the fresh data while keeping
client state and scroll position. It is not a browser reload.
// app/components/EinblickEditBoot.tsx
'use client'
import { EinblickEditProvider } from '@einblick/sdk/react'
import { useRouter } from 'next/navigation'
export default function EinblickEditBoot({
children,
}: {
children: React.ReactNode
}) {
const siteKey = process.env.NEXT_PUBLIC_EINBLICK_SITE_KEY
const router = useRouter()
const handleSave = async (event: { binding: { resourceSlug: string } }) => {
try {
await fetch('/api/einblick/revalidate', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ resourceSlug: event.binding.resourceSlug }),
cache: 'no-store',
})
} catch {
// Keep the optimistic edit state even if revalidation fails.
}
router.refresh()
}
if (!siteKey) {
return children
}
return (
<EinblickEditProvider siteKey={siteKey} onSave={handleSave}>
{children}
</EinblickEditProvider>
)
}Then in the root layout:
// app/layout.tsx
import EinblickEditBoot from '@/components/EinblickEditBoot'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<body>
<EinblickEditBoot>{children}</EinblickEditBoot>
</body>
</html>
)
}Don't skip
router.refresh(). Without it, the server cache is clean but the page the user is looking at is still showing the old server-rendered payload. In-page edits toimage/images/file/filesfields will appear stale (or partially updated) until the next navigation — a full reload is not required,router.refresh()is the correct primitive.
4. Editable bindings on rendered content
Use createEditableBinding in your server-side data mappers to produce record
and field bindings that travel alongside the content, and render them with
EditableRegion, EditableText, or EditableImage:
// app/projects/[slug]/page.tsx
import { EditableRegion, EditableText } from '@einblick/sdk/react'
import { getProjectBySlug } from '@/lib/data'
export default async function ProjectPage({
params,
}: {
params: { slug: string }
}) {
const project = await getProjectBySlug(params.slug)
if (!project) return null
return (
<article>
<EditableText as='h1' binding={project.bindings.title}>
{project.title}
</EditableText>
<EditableRegion binding={project.bindings.description}>
<p>{project.description}</p>
</EditableRegion>
</article>
)
}For file/image bindings rendered as galleries (arrays), wrap the whole gallery
in an EditableRegion bound to the field. Do not rely on the SDK patching
individual <img> elements after save — the server returns file IDs, not URLs,
and a single DOM selector can't represent an array of assets. The
router.refresh() step above is what re-renders the gallery correctly.
For list UIs, wrap the repeated surface with EditableCollection and keep
record-level EditableRegion bindings on each item. This gives editors one
clear list outline, while each rendered item keeps its normal hover-to-edit
state:
import {
EditableCollection,
EditableRegion,
createEditableCollectionBinding,
} from '@einblick/sdk/react'
const projectListBinding = createEditableCollectionBinding({
resourceSlug: 'projects',
label: 'Projects',
})
<EditableCollection as='ul' binding={projectListBinding}>
{projects.map((project) => (
<EditableRegion as='li' key={project.id} binding={project.bindings.region}>
...
</EditableRegion>
))}
</EditableCollection>If the list should expose an add action in edit mode, set showCreateButton.
The SDK renders a hidden button at the end of the collection and reveals it only
for authenticated editors; for ul and ol collections the button is wrapped
in a final li.
<EditableCollection as='ul' binding={projectListBinding} showCreateButton>
{projects.map((project) => (
<EditableRegion as='li' key={project.id} binding={project.bindings.region}>
...
</EditableRegion>
))}
</EditableCollection>Do not attach the list create action to the first item with
projects[0]?.bindings.collection. That makes the add button look like it
belongs to one record and fails for empty lists. EditableCollection only needs
the resource slug, so empty lists can still expose the create action.
5. Environment variables
| Variable | Where | Notes |
| ------------------------------- | ----------- | ---------------------------------------------------------------------- |
| EINBLICK_API_KEY | server-only | Never expose to the client. Used by createGeneratedEinblickClient(). |
| NEXT_PUBLIC_EINBLICK_SITE_KEY | public | Site key for the in-page editor. Public by design; scoped to the site. |
6. Common mistakes
- Calling the SDK client from a client component. Import it only from
server code.
'use server'or'server-only'at the top of the module prevents accidents. - Skipping the
cache()wrapper. Without it, multiple components in the same render will each make their own HTTP call. - Only invalidating tags on static App Router pages. The CMS fetch may be
fresh while Vercel still serves prerendered route output. Pair tags with a
route invalidation such as
revalidatePath("/", "layout"), or a narrower affected-path map for larger apps. - Omitting
router.refresh()inonSave. The server cache will be correct, but the user will stare at stale DOM until they navigate.
@einblick/sdk/next — App Router helpers
For Next.js App Router projects the next subpath ships drop-ins that
replace the cache-tag, revalidate-route, and edit-boot boilerplate every
einblick consumer used to copy by hand:
// lib/einblick-cache.ts
import { createEinblickCmsTags } from '@einblick/sdk/next'
export const einblickTags = createEinblickCmsTags({
fanOut: { 'site-settings': ['gallery-slides'] },
})// app/api/einblick/revalidate/route.ts
import { createEinblickRevalidateHandler } from '@einblick/sdk/next'
import { einblickTags } from '@/lib/einblick-cache'
export const POST = createEinblickRevalidateHandler({
tags: einblickTags,
secret: process.env.EINBLICK_REVALIDATE_SECRET,
})// app/layout.tsx
import { EinblickNextEditBoot } from '@einblick/sdk/next'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<body>
<EinblickNextEditBoot>{children}</EinblickNextEditBoot>
</body>
</html>
)
}The boot reads NEXT_PUBLIC_EINBLICK_SITE_KEY automatically and calls
router.refresh() after saves. Cache invalidation is delivered separately by
Einblick's server-to-server webhook. The revalidation route fails closed unless
EINBLICK_REVALIDATE_SECRET is set; keep this value server-only and configure
the same secret on the external site in Einblick. Never expose it through a
NEXT_PUBLIC_* variable. The handler defaults to
revalidateTag(tag, { expire: 0 }) and revalidatePath("/", "layout"). For
larger apps, pass paths: [] to disable route invalidation, or pass a
resource-aware paths function to invalidate only affected pages.
Direct edits made inside Einblick use the same route. In System Settings → API,
register the external site's allowed origins, keep the default
/api/einblick/revalidate endpoint unless the host uses another path, and set
the same revalidation secret there if the route requires one.
Asset helpers
The core package ships ergonomic image-CDN helpers so consumers don't hand-roll URL composition. Common cases:
import {
getEinblickAssetCdnUrl,
getEinblickAssetOgUrl,
getEinblickAssetPreviewUrl,
getEinblickAssetAlt,
isEinblickVideoAsset,
} from '@einblick/sdk'
const heroSrc = getEinblickAssetCdnUrl(asset, {
width: 1800,
format: 'webp',
quality: 84,
})
const ogSrc = getEinblickAssetOgUrl(asset) // 1200×630, cover, jpeg
const preview = getEinblickAssetPreviewUrl(asset) // long-edge 1800, webp
const alt = getEinblickAssetAlt(asset, 'Fallback alt text')For React consumers, drop-in <EinblickImage> from @einblick/sdk/react
renders the asset with sensible defaults:
import { EinblickImage } from '@einblick/sdk/react'
;<EinblickImage
asset={tour.image_banner}
alt={getEinblickAssetAlt(tour.image_banner, tour.name)}
transform={{ width: 1600, format: 'webp', quality: 82 }}
/>The component returns null when the asset is missing — no broken-image
placeholder. Videos are passed through untransformed.
Raw API
GET /api/v1/schemareturns the key-scoped OpenAPI 3.1 documentGET /api/v1lists accessible resourcesGET /api/v1/{resource}lists records for a resourceGET /api/v1/{resource}/{recordSlug}fetches a single record
Authenticate with:
Authorization: Bearer api_...