> For the complete Lynkow documentation index in agent-friendly format, see [llms.txt](/llms.txt).

# Draft Preview

**Publié le** : 2026-08-14
**Catégorie** : Advanced

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

> **Reference** - This guide is a tutorial. For exhaustive, always-current signatures and field lists, see the auto-generated reference:
> 
> - SDK: [`ContentsService`](/docs/sdk/services/sdk-contents-service)
> - API: [Storefront: Content](/docs/storefront-api/storefront-content)

---

## Prerequisites

- `lynkow` SDK installed and configured ([Quick Start](/docs/sdk/getting-started/quick-start))
- 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

### 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-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 /
```