Preview unpublished content on your Next.js site before it goes live, using Next.js Draft Mode and the Lynkow V1 admin API.
Prerequisites
lynkowSDK installed and configured (Getting Started guide)Next.js 15 App Router project with TypeScript
A Lynkow API token with
contents.viewpermission (created in the Lynkow admin dashboard under Settings > API Tokens)
1. Why the Public SDK Cannot Fetch Drafts
The lynkow package is a public SDK. It authenticates requests using only a siteId -- there is no secret token or user session. For security, the public API exclusively returns content with status: 'published'. Draft, scheduled, and archived content are never exposed.
This is by design: the public SDK is meant to be used in frontend code where the siteId is visible to anyone. If drafts were accessible through it, anyone who knows your site ID could read unpublished content.
To fetch draft content, you need the V1 admin API, which requires a Bearer token with appropriate permissions. This token is a secret and must only be used server-side.
2. Environment Setup
Add two environment variables to your .env.local file:
# .env.local
# A secret shared between your Lynkow admin dashboard and your Next.js app.
# Generate a random string (e.g., openssl rand -hex 32).
# This prevents unauthorized users from enabling preview mode.
LYNKOW_PREVIEW_SECRET=your-random-secret-string-here
# Your Lynkow V1 API token with contents.view permission.
# Create this in the Lynkow admin: Settings > API Tokens.
# This is a SECRET -- never expose it to the browser.
LYNKOW_API_TOKEN=lynk_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Your public site ID (you already have this from the SDK setup)
NEXT_PUBLIC_LYNKOW_SITE_ID=your-site-uuidImportant: These are server-only variables (no NEXT_PUBLIC_ prefix for the secret and token). They are only accessible in API routes and server components.
3. Preview Entry Route
This API route validates the preview secret, confirms the content exists via the V1 API, enables Draft Mode, and redirects the user to the article page.
// app/api/preview/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
import { NextRequest } from 'next/server'
const PREVIEW_SECRET = process.env.LYNKOW_PREVIEW_SECRET
const API_TOKEN = process.env.LYNKOW_API_TOKEN
const SITE_ID = process.env.NEXT_PUBLIC_LYNKOW_SITE_ID
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const secret = searchParams.get('secret')
const slug = searchParams.get('slug')
// 1. Validate the preview secret
if (!secret || secret !== PREVIEW_SECRET) {
return new Response('Invalid preview secret', { status: 401 })
}
// 2. Validate that a slug was provided
if (!slug) {
return new Response('Missing slug parameter', { status: 400 })
}
// 3. Validate server-side configuration
if (!API_TOKEN || !SITE_ID) {
return new Response('Preview is not configured', { status: 500 })
}
// 4. Check that the content exists in the V1 API (any status)
const apiResponse = await fetch(
`${process.env.NEXT_PUBLIC_LYNKOW_API_URL ?? 'https://api.lynkow.com'}/v1/contents/slug/${encodeURIComponent(slug)}`,
{
headers: {
'Authorization': `Bearer ${API_TOKEN}`,
'X-Site-Id': SITE_ID,
'Accept': 'application/json',
},
// Don't cache this request -- we need to check current state
cache: 'no-store',
}
)
if (!apiResponse.ok) {
return new Response(
`Content not found for slug "${slug}" (status ${apiResponse.status})`,
{ status: 404 }
)
}
// 5. Enable Draft Mode -- sets an httpOnly cookie
const draft = await draftMode()
draft.enable()
// 6. Redirect to the article page
// The article page will detect draft mode and fetch via V1 API
redirect(`/blog/${slug}`)
}How it works:
The Lynkow admin dashboard has a "Preview" button. When clicked, it opens
https://yoursite.com/api/preview?secret=xxx&slug=my-articlein a new tab.The route validates the shared secret to prevent unauthorized access.
It calls the V1 API to confirm the content exists (regardless of status).
draftMode().enable()sets a secure, httpOnly cookie (__prerender_bypass) that Next.js checks on every request.The user is redirected to the article page, where the cookie triggers fresh (non-cached) rendering.
4. Exit Preview Route
Provide a way for editors to leave preview mode:
// app/api/preview/exit/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
export async function GET() {
const draft = await draftMode()
draft.disable()
redirect('/')
}You can link to this from the preview banner (see section 7).
5. Helper: fetchDraftContent
A server-side function that fetches content from the V1 admin API by slug. This returns content in any status -- draft, scheduled, published, or archived:
// lib/draft.ts
const API_BASE_URL = process.env.NEXT_PUBLIC_LYNKOW_API_URL ?? 'https://api.lynkow.com'
const API_TOKEN = process.env.LYNKOW_API_TOKEN!
const SITE_ID = process.env.NEXT_PUBLIC_LYNKOW_SITE_ID!
export interface DraftContent {
id: number
title: string
slug: string
status: 'draft' | 'scheduled' | 'published' | 'archived'
body: unknown
html: string
excerpt: string | null
featuredImage: string | null
author: {
id: number
fullName: string
} | null
category: {
id: number
title: string
slug: string
} | null
tags: Array<{
id: number
title: string
slug: string
}>
meta: {
metaTitle: string | null
metaDescription: string | null
ogImage: string | null
} | null
publishedAt: string | null
createdAt: string
updatedAt: string
}
interface V1Response {
data: DraftContent
}
/**
* Fetch content from the V1 admin API (supports all statuses including draft).
* This function must only be called server-side -- it uses a secret API token.
*/
export async function fetchDraftContent(slug: string): Promise<DraftContent | null> {
const response = await fetch(
`${API_BASE_URL}/v1/contents/slug/${encodeURIComponent(slug)}`,
{
headers: {
'Authorization': `Bearer ${API_TOKEN}`,
'X-Site-Id': SITE_ID,
'Accept': 'application/json',
},
// Always fetch fresh data in preview mode
cache: 'no-store',
}
)
if (response.status === 404) {
return null
}
if (!response.ok) {
throw new Error(
`V1 API error: ${response.status} ${response.statusText}`
)
}
const json: V1Response = await response.json()
return json.data
}
/**
* Fetch content by ID from the V1 admin API.
*/
export async function fetchDraftContentById(id: number): Promise<DraftContent | null> {
const response = await fetch(
`${API_BASE_URL}/v1/contents/${id}`,
{
headers: {
'Authorization': `Bearer ${API_TOKEN}`,
'X-Site-Id': SITE_ID,
'Accept': 'application/json',
},
cache: 'no-store',
}
)
if (response.status === 404) {
return null
}
if (!response.ok) {
throw new Error(
`V1 API error: ${response.status} ${response.statusText}`
)
}
const json: V1Response = await response.json()
return json.data
}6. Modified Article Page
Update your article page to check whether Draft Mode is active. If it is, fetch from the V1 admin API. Otherwise, use the public SDK as normal:
// app/blog/[slug]/page.tsx
import { draftMode } from 'next/headers'
import { notFound } from 'next/navigation'
import { lynkow, isLynkowError } from '@/lib/lynkow'
import { fetchDraftContent } from '@/lib/draft'
import { PreviewBanner } from '@/components/preview-banner'
interface Props {
params: Promise<{ slug: string }>
}
export default async function ArticlePage({ params }: Props) {
const { slug } = await params
const draft = await draftMode()
const isPreview = draft.isEnabled
let article: {
title: string
html: string
status?: string
excerpt: string | null
featuredImage: string | null
publishedAt: string | null
author: { fullName: string } | null
category: { title: string; slug: string } | null
tags: Array<{ title: string; slug: string }>
}
if (isPreview) {
// Draft Mode: fetch from V1 admin API (supports all statuses)
const draftContent = await fetchDraftContent(slug)
if (!draftContent) {
notFound()
}
article = draftContent
} else {
// Normal mode: fetch from public SDK (published only)
try {
article = await lynkow.contents.getBySlug(slug)
} catch (error: unknown) {
if (isLynkowError(error) && error.code === 'NOT_FOUND') {
notFound()
}
throw error
}
}
return (
<>
{isPreview && (
<PreviewBanner
status={'status' in article ? (article.status as string) : 'published'}
/>
)}
<article className="prose mx-auto max-w-3xl py-12">
{article.featuredImage && (
<img
src={article.featuredImage}
alt={article.title}
className="mb-8 w-full rounded-lg"
/>
)}
<h1>{article.title}</h1>
{article.author && (
<p className="text-gray-500">By {article.author.fullName}</p>
)}
{article.publishedAt && (
<time className="text-sm text-gray-400">
{new Date(article.publishedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
)}
<div dangerouslySetInnerHTML={{ __html: article.html }} />
{article.tags.length > 0 && (
<div className="mt-8 flex gap-2">
{article.tags.map((tag) => (
<span
key={tag.slug}
className="rounded bg-gray-100 px-2 py-1 text-sm text-gray-600"
>
{tag.title}
</span>
))}
</div>
)}
</article>
</>
)
}
// When in preview mode, disable static generation so we always get fresh data
export async function generateStaticParams() {
// Only pre-render published articles (via public SDK)
const response = await lynkow.contents.list({ limit: 100 })
return response.data.map((article) => ({
slug: article.slug,
}))
}How the flow works:
When Draft Mode is disabled (normal visitors),
draftMode().isEnabledisfalse. The page uses the public SDK and can be statically generated or cached.When Draft Mode is enabled (editor previewing),
draftMode().isEnabledistrue. The page callsfetchDraftContent()which hits the V1 API with a Bearer token, bypassing the "published only" filter. Next.js also bypasses its static cache so the editor sees the latest content.
7. Preview Banner Component
A visual indicator that tells editors they are in preview mode, with the content status and an exit button:
// components/preview-banner.tsx
'use client'
interface Props {
status: string
}
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
draft: { label: 'Draft', color: 'bg-yellow-500' },
scheduled: { label: 'Scheduled', color: 'bg-blue-500' },
published: { label: 'Published', color: 'bg-green-500' },
archived: { label: 'Archived', color: 'bg-gray-500' },
}
export function PreviewBanner({ status }: Props) {
const statusInfo = STATUS_LABELS[status] ?? {
label: status,
color: 'bg-gray-500',
}
return (
<div className="fixed inset-x-0 bottom-0 z-50 border-t border-amber-300 bg-amber-50 px-4 py-3">
<div className="mx-auto flex max-w-5xl items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-amber-800">
Preview Mode
</span>
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium text-white ${statusInfo.color}`}
>
{statusInfo.label}
</span>
<span className="text-sm text-amber-600">
This page is showing unpublished content that is not visible to the public.
</span>
</div>
<a
href="/api/preview/exit"
className="rounded border border-amber-300 bg-white px-3 py-1.5 text-sm font-medium text-amber-800 hover:bg-amber-100"
>
Exit Preview
</a>
</div>
</div>
)
}8. Security Considerations
Preview Secret Validation
The preview secret prevents arbitrary users from enabling draft mode. Without it, anyone could visit /api/preview?slug=x and toggle the cookie.
Generate a strong random secret:
openssl rand -hex 32Store it in environment variables on both sides (Lynkow admin webhook config and
.env.local)Never include it in client-side code
httpOnly Cookie
draftMode().enable() sets a secure, httpOnly cookie (__prerender_bypass). This cookie:
Cannot be read by client-side JavaScript (httpOnly)
Is scoped to your domain
Persists until the browser session ends or
draftMode().disable()is called
API Token Security
The LYNKOW_API_TOKEN grants read access to all content including drafts. Protect it:
Never prefix it with
NEXT_PUBLIC_-- it must stay server-onlyOnly use it in API routes (
app/api/) and server componentsCreate a token with minimal permissions (
contents.viewonly)Rotate it periodically in the Lynkow admin dashboard
Rate Limiting
The V1 API has its own rate limits independent of the public API. In preview mode, every page load makes a fresh API call (no caching). This is fine for individual editor previews but would be a problem if many users had draft mode enabled simultaneously. In practice this is not a concern since only editors with the preview secret can enable it.
9. Complete File Reference
Here is every file involved in the preview system:
.env.local
# Public SDK
NEXT_PUBLIC_LYNKOW_SITE_ID=your-site-uuid
NEXT_PUBLIC_LYNKOW_API_URL=https://api.lynkow.com
# Preview (server-only)
LYNKOW_PREVIEW_SECRET=a1b2c3d4e5f6...
LYNKOW_API_TOKEN=lynk_xxxxxxxxxxxxlib/draft.ts
const API_BASE_URL = process.env.NEXT_PUBLIC_LYNKOW_API_URL ?? 'https://api.lynkow.com'
const API_TOKEN = process.env.LYNKOW_API_TOKEN!
const SITE_ID = process.env.NEXT_PUBLIC_LYNKOW_SITE_ID!
export interface DraftContent {
id: number
title: string
slug: string
status: 'draft' | 'scheduled' | 'published' | 'archived'
body: unknown
html: string
excerpt: string | null
featuredImage: string | null
author: {
id: number
fullName: string
} | null
category: {
id: number
title: string
slug: string
} | null
tags: Array<{
id: number
title: string
slug: string
}>
meta: {
metaTitle: string | null
metaDescription: string | null
ogImage: string | null
} | null
publishedAt: string | null
createdAt: string
updatedAt: string
}
interface V1Response {
data: DraftContent
}
export async function fetchDraftContent(slug: string): Promise<DraftContent | null> {
const response = await fetch(
`${API_BASE_URL}/v1/contents/slug/${encodeURIComponent(slug)}`,
{
headers: {
'Authorization': `Bearer ${API_TOKEN}`,
'X-Site-Id': SITE_ID,
'Accept': 'application/json',
},
cache: 'no-store',
}
)
if (response.status === 404) {
return null
}
if (!response.ok) {
throw new Error(`V1 API error: ${response.status} ${response.statusText}`)
}
const json: V1Response = await response.json()
return json.data
}
export async function fetchDraftContentById(id: number): Promise<DraftContent | null> {
const response = await fetch(
`${API_BASE_URL}/v1/contents/${id}`,
{
headers: {
'Authorization': `Bearer ${API_TOKEN}`,
'X-Site-Id': SITE_ID,
'Accept': 'application/json',
},
cache: 'no-store',
}
)
if (response.status === 404) {
return null
}
if (!response.ok) {
throw new Error(`V1 API error: ${response.status} ${response.statusText}`)
}
const json: V1Response = await response.json()
return json.data
}app/api/preview/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
import { NextRequest } from 'next/server'
const PREVIEW_SECRET = process.env.LYNKOW_PREVIEW_SECRET
const API_TOKEN = process.env.LYNKOW_API_TOKEN
const SITE_ID = process.env.NEXT_PUBLIC_LYNKOW_SITE_ID
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const secret = searchParams.get('secret')
const slug = searchParams.get('slug')
if (!secret || secret !== PREVIEW_SECRET) {
return new Response('Invalid preview secret', { status: 401 })
}
if (!slug) {
return new Response('Missing slug parameter', { status: 400 })
}
if (!API_TOKEN || !SITE_ID) {
return new Response('Preview is not configured', { status: 500 })
}
const apiResponse = await fetch(
`${process.env.NEXT_PUBLIC_LYNKOW_API_URL ?? 'https://api.lynkow.com'}/v1/contents/slug/${encodeURIComponent(slug)}`,
{
headers: {
'Authorization': `Bearer ${API_TOKEN}`,
'X-Site-Id': SITE_ID,
'Accept': 'application/json',
},
cache: 'no-store',
}
)
if (!apiResponse.ok) {
return new Response(
`Content not found for slug "${slug}" (status ${apiResponse.status})`,
{ status: 404 }
)
}
const draft = await draftMode()
draft.enable()
redirect(`/blog/${slug}`)
}app/api/preview/exit/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
export async function GET() {
const draft = await draftMode()
draft.disable()
redirect('/')
}components/preview-banner.tsx
'use client'
interface Props {
status: string
}
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
draft: { label: 'Draft', color: 'bg-yellow-500' },
scheduled: { label: 'Scheduled', color: 'bg-blue-500' },
published: { label: 'Published', color: 'bg-green-500' },
archived: { label: 'Archived', color: 'bg-gray-500' },
}
export function PreviewBanner({ status }: Props) {
const statusInfo = STATUS_LABELS[status] ?? {
label: status,
color: 'bg-gray-500',
}
return (
<div className="fixed inset-x-0 bottom-0 z-50 border-t border-amber-300 bg-amber-50 px-4 py-3">
<div className="mx-auto flex max-w-5xl items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-amber-800">
Preview Mode
</span>
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium text-white ${statusInfo.color}`}
>
{statusInfo.label}
</span>
<span className="text-sm text-amber-600">
This page is showing unpublished content that is not visible to the public.
</span>
</div>
<a
href="/api/preview/exit"
className="rounded border border-amber-300 bg-white px-3 py-1.5 text-sm font-medium text-amber-800 hover:bg-amber-100"
>
Exit Preview
</a>
</div>
</div>
)
}app/blog/[slug]/page.tsx
import { draftMode } from 'next/headers'
import { notFound } from 'next/navigation'
import { lynkow, isLynkowError } from '@/lib/lynkow'
import { fetchDraftContent } from '@/lib/draft'
import { PreviewBanner } from '@/components/preview-banner'
interface Props {
params: Promise<{ slug: string }>
}
export default async function ArticlePage({ params }: Props) {
const { slug } = await params
const draft = await draftMode()
const isPreview = draft.isEnabled
let article: {
title: string
html: string
status?: string
excerpt: string | null
featuredImage: string | null
publishedAt: string | null
author: { fullName: string } | null
category: { title: string; slug: string } | null
tags: Array<{ title: string; slug: string }>
}
if (isPreview) {
const draftContent = await fetchDraftContent(slug)
if (!draftContent) {
notFound()
}
article = draftContent
} else {
try {
article = await lynkow.contents.getBySlug(slug)
} catch (error: unknown) {
if (isLynkowError(error) && error.code === 'NOT_FOUND') {
notFound()
}
throw error
}
}
return (
<>
{isPreview && (
<PreviewBanner
status={'status' in article ? (article.status as string) : 'published'}
/>
)}
<article className="prose mx-auto max-w-3xl py-12">
{article.featuredImage && (
<img
src={article.featuredImage}
alt={article.title}
className="mb-8 w-full rounded-lg"
/>
)}
<h1>{article.title}</h1>
{article.author && (
<p className="text-gray-500">By {article.author.fullName}</p>
)}
{article.publishedAt && (
<time className="text-sm text-gray-400">
{new Date(article.publishedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
)}
<div dangerouslySetInnerHTML={{ __html: article.html }} />
{article.tags.length > 0 && (
<div className="mt-8 flex gap-2">
{article.tags.map((tag) => (
<span
key={tag.slug}
className="rounded bg-gray-100 px-2 py-1 text-sm text-gray-600"
>
{tag.title}
</span>
))}
</div>
)}
</article>
</>
)
}
export async function generateStaticParams() {
const response = await lynkow.contents.list({ limit: 100 })
return response.data.map((article) => ({
slug: article.slug,
}))
}How It All Fits Together
1. Editor clicks "Preview" in Lynkow admin
└─> Opens: https://yoursite.com/api/preview?secret=xxx&slug=my-draft-article
2. app/api/preview/route.ts
├─ Validates secret ✓
├─ Calls V1 API to verify slug exists ✓
├─ Enables Draft Mode (sets __prerender_bypass cookie)
└─ Redirects to /blog/my-draft-article
3. app/blog/[slug]/page.tsx
├─ draftMode().isEnabled === true
├─ Calls fetchDraftContent('my-draft-article')
│ └─ GET /v1/contents/slug/my-draft-article (Bearer token)
│ └─ Returns content with status: 'draft'
├─ Renders article with PreviewBanner
└─ Next.js skips static cache (draft mode bypasses it)
4. Editor clicks "Exit Preview"
└─> GET /api/preview/exit
├─ Disables Draft Mode (removes cookie)
└─ Redirects to /