Preview unpublished content on your Next.js site before it goes live, using Next.js Draft Mode and the Lynkow V1 admin API.


Prerequisites

  • lynkow SDK installed and configured (Getting Started guide)

  • Next.js 15 App Router project with TypeScript

  • A Lynkow API token with contents.view permission (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:

Bash
# .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-uuid

Important: 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.

TypeScript
// 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:

  1. The Lynkow admin dashboard has a "Preview" button. When clicked, it opens https://yoursite.com/api/preview?secret=xxx&slug=my-article in a new tab.

  2. The route validates the shared secret to prevent unauthorized access.

  3. It calls the V1 API to confirm the content exists (regardless of status).

  4. draftMode().enable() sets a secure, httpOnly cookie (__prerender_bypass) that Next.js checks on every request.

  5. 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:

TypeScript
// 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:

TypeScript
// 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:

TypeScript
// 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:

  1. When Draft Mode is disabled (normal visitors), draftMode().isEnabled is false. The page uses the public SDK and can be statically generated or cached.

  2. When Draft Mode is enabled (editor previewing), draftMode().isEnabled is true. The page calls fetchDraftContent() 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:

TypeScript
// 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 32

  • Store it in environment variables on both sides (Lynkow admin webhook config and .env.local)

  • Never include it in client-side code

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-only

  • Only use it in API routes (app/api/) and server components

  • Create a token with minimal permissions (contents.view only)

  • 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

Bash
# 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_xxxxxxxxxxxx

lib/draft.ts

TypeScript
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

TypeScript
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

TypeScript
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

TypeScript
'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

TypeScript
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 /