@estokad/next
v0.1.6
Published
Next.js adapter: draft mode helpers, EstokadProvider, auto-tagging Estokad.* components.
Readme
@estokad/next
Next.js App Router adapter for Estokad: typed content fetching (re-exports @estokad/sdk), draft-mode route handlers, the visual-edit overlay, and auto-tagging server components.
Install
pnpm add @estokad/next@estokad/sdk is a dependency — you do not install it separately; createClient and the client types are re-exported.
Compatibility
Peer range: Next ^14.2 || ^15, React ^18.2 || ^19.
| Surface | Next 14.2 / React 18 | Next 15 / React 19 |
| --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ------------------ |
| createClient / .gql, renderRichText, estokadDraftMode, estokadOverlayRoute, signDraftToken / verifyDraftToken | runs + typechecks | runs + typechecks |
| async server components EstokadProvider, <Estokad.*> | runs, but does not typecheck under @types/react@18 | runs + typechecks |
draftMode() / cookies() are async on Next 15 and sync on Next 14.2; the package awaits them, which is a no-op passthrough on 14 and required on 15 — so the runtime is correct on both.
The async-component caveat is the well-known @types/react@18 limitation: an async function component types as (props) => Promise<Element>, which React 18's JSX types reject (Promise<Element> is not a valid ReactNode). React 19's types fixed this. On a React-18-pinned project you have three options, in order of preference:
- Skip the React components. Visual edit is delivered by the injected overlay script, not the components. Mount
estokadOverlayRouteand inject<script type="module" src="/api/estokad/overlay">yourself in your layout when(await draftMode()).isEnabled— about six lines, fully typed on React 18.EstokadProvideris only a convenience wrapper around exactly that. - Render
<Estokad.*>and suppress the call-site type error (// @ts-expect-error async server component — runs on Next 14, typed on React 19). Runtime is correct. - Move that project's
@types/reactto v19 (types only; React runtime can stay 18) if your toolchain allows it.
The data, draft-mode, overlay, rich-text, and token surfaces — the bulk of the adapter — are fully typed and run on Next 14.2 / React 18 with no workaround.
Type your content
The client is keyed by content type. Declare the types your workspace exposes by augmenting ContentTypes (the interface lives in @estokad/sdk; estokad push will generate this file for you once codegen is wired, until then declare it by hand):
// estokad.d.ts
import type {} from '@estokad/next'
declare module '@estokad/sdk' {
interface ContentTypes {
article: {
title: string
slug: string
body: import('@estokad/next').TiptapDoc
hero: string | null
}
siteSettings: { siteName: string }
}
}Each accessor cms.<type> is then typed against the matching interface.
Create the client
import { createClient } from '@estokad/next'
const cms = createClient({
workspace: 'your-workspace-slug',
apiUrl: 'https://api.estokad.com',
apiKey: process.env.ESTOKAD_PUBLIC_KEY!, // a delivery/read key
})Pass draft: true (with a draft-scoped key) to read unpublished content — typically only inside the draft-mode branch.
Fetch + render
// app/articles/[slug]/page.tsx
import { createClient, Estokad } from '@estokad/next'
const cms = createClient({
workspace: 'your-workspace-slug',
apiUrl: 'https://api.estokad.com',
apiKey: process.env.ESTOKAD_PUBLIC_KEY!,
})
export default async function ArticlePage({ params }: { params: { slug: string } }) {
const article = await cms.article.get({ slug: params.slug })
if (!article) return null
// EntryEnvelope<T> flattens the content fields onto the object, plus
// `id` and an optional `_meta` ({ status, publishedAt, updatedAt, createdAt }).
return (
<article>
<Estokad.Text
value={article.title}
entryId={article.id}
contentType="article"
fieldName="title"
as="h1"
/>
<Estokad.RichText
value={article.body}
entryId={article.id}
contentType="article"
fieldName="body"
/>
<Estokad.Image
src={article.hero}
alt={article.title}
entryId={article.id}
contentType="article"
fieldName="hero"
/>
</article>
)
}cms.<type> exposes get({ id } | { slug }), list({ first?, offset? }), create(data), update(id, data), publish(id), unpublish(id), delete(id). Raw GraphQL is cms.gql<T>(query, variables?).
The <Estokad.*> components (Text, RichText, Number, Date, Image, Field) emit plain HTML in production. In draft mode they add data-estokad-* attributes the overlay reads for click-to-edit. Every one takes entryId, contentType, fieldName plus its value/src. They're async Server Components — see Compatibility on React 18.
Images with next/image
Estokad serves assets through imgproxy (Bunny.net-fronted): the URL you get is already resized, format-converted, and signed. Don't put Next's optimizer on top of it — it would re-process the image, and it forces you to list the (deployment-specific) CDN host in images.remotePatterns.
Use the supplied loader instead. It returns the Estokad URL untouched, which bypasses remotePatterns entirely (Next only enforces that for its built-in optimizer) and avoids the double image pipeline, while keeping <Image>'s layout / priority / lazy-loading / CLS handling:
import Image from 'next/image'
import { estokadImageLoader } from '@estokad/next'
;<Image loader={estokadImageLoader} src={asset.url} width={1200} height={630} alt="" />Project-wide, set it as the default loader so you never touch remotePatterns for Estokad assets:
// estokad-image-loader.ts
export { estokadImageLoader as default } from '@estokad/next'// next.config.js
module.exports = {
images: { loader: 'custom', loaderFile: './estokad-image-loader.ts' },
}Choose the size you need when you resolve the asset URL server-side (the variant-url endpoint takes width/quality); the loader does not resize — resizing is Estokad's job, done before the URL exists.
Draft mode
The Studio preview link calls a route on your site that toggles Next.js draft mode:
// app/api/draft/route.ts
import { estokadDraftMode } from '@estokad/next/draft'
export const GET = estokadDraftMode.enable({
workspace: 'your-workspace-slug',
previewSecret: process.env.ESTOKAD_PREVIEW_SECRET!,
// or, recommended in production: jwksUrl: 'https://api.estokad.com/v1/<ws>/.well-known/jwks.json'
})
// disable is itself the handler — do not call it
export const POST = estokadDraftMode.disableestokadDraftMode.enable(config) returns the route handler; estokadDraftMode.disable is the handler. Token verification is HMAC (previewSecret) or RS256/JWKS (jwksUrl, takes precedence). Low-level codecs signDraftToken / verifyDraftToken / verifyDraftTokenJwks are exported from the package root.
Visual edit overlay
Mount the overlay route once:
// app/api/estokad/overlay/route.ts
export { estokadOverlayRoute as GET } from '@estokad/next/overlay-route'Then either wrap your layout with EstokadProvider (it injects <script type="module" src="/api/estokad/overlay"> when draft mode is on, and is a zero-overhead pass-through otherwise):
// app/layout.tsx
import { EstokadProvider } from '@estokad/next'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<EstokadProvider>{children}</EstokadProvider>
</body>
</html>
)
}…or, on React 18 (to avoid the async-component type caveat), inline the same six lines yourself:
// app/layout.tsx
import { draftMode } from 'next/headers'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const { isEnabled } = await draftMode()
return (
<html>
<body>
{children}
{isEnabled ? <script type="module" src="/api/estokad/overlay" async /> : null}
</body>
</html>
)
}EstokadProvider accepts an optional overlaySrc to override the default /api/estokad/overlay path.
Documentation
Full reference at docs.estokad.com/docs/visual-edit and docs.estokad.com/docs/getting-started.
License
Apache-2.0. Estokad is a trademark of Samarkand Industries OÜ; this license grants no trademark rights (Apache-2.0 §6).
