@humaan/payload-preview-links
v0.1.0
Published
Share unpublished Payload CMS content with people who have no CMS account, via revocable links that put the recipient into Next.js draft mode.
Maintainers
Readme
@humaan/payload-preview-links
Share unpublished Payload content with people who have no CMS account.
An editor mints a shareable URL from the document's sidebar; anyone who opens it
is put into Next.js draft mode and redirected to the document's public URL. No
login, no payload-token cookie. Links are database-backed, so they can be
listed and revoked — revocation bites sessions that are already open.
A redeemed link grants a normal, site-wide draft session — the same one an editor gets. See Narrowing a link to one page if that is too broad for your project.
Requires Payload 3 and a Next.js host app.
Install
pnpm add @humaan/payload-preview-linksRegister the plugin
// payload.config.ts
import { previewLinksPlugin } from '@humaan/payload-preview-links'
export default buildConfig({
collections: [Pages, Posts],
plugins: [
previewLinksPlugin({
collections: ['pages', 'posts'],
resolveUrl: ({ doc }) => `/${doc.slug}`,
}),
],
})Every collection listed must have versions.drafts — the plugin throws at config
time, naming the offender, if one does not.
Mount the redeem handler
// src/app/(frontend)/api/preview/share/route.ts
import config from '@payload-config'
import { createRedeemHandler } from '@humaan/payload-preview-links/rsc'
export const GET = createRedeemHandler(config)The route's path must match the plugin's redeemPath (default
/api/preview/share), since that is what the admin UI uses to build link URLs.
Check the session when fetching data
Draft mode being on is not enough: a revoked link has to stop working for a recipient whose browser still holds the cookie. So re-check per request.
import config from '@payload-config'
import { getPreviewSession } from '@humaan/payload-preview-links/rsc'
import { draftMode } from 'next/headers'
import { cache } from 'react'
export const getDraftContext = cache(async () => {
const { isEnabled } = await draftMode()
const session = await getPreviewSession(config)
// 'none' means no share cookie — an ordinary editor draft session.
return { draft: isEnabled && session.status !== 'invalid', session }
})getPreviewSession returns:
| Status | Meaning |
| --- | --- |
| none | No share cookie. Not a share session — returns before touching the database. |
| invalid | Cookie present, but the link is missing, revoked, expired, or its document no longer resolves to a path. |
| active | Cookie present and the link is live; carries the link record and the document's current targetPath. |
targetPath is re-derived from the document each request with draft: true, so
it follows a slug edited in draft. Useful for a "you are previewing About Us"
banner, and required if you narrow the session as below.
A redeemed link grants a normal, site-wide draft session — the same one an editor gets. Entitlement checks elsewhere in your app (gated downloads and the like) still apply and are unaffected by draft mode.
Narrowing a link to one page
Optional, and it is real work. previewSessionAllows(session, pathname) returns
true for none (an editor's own session, never narrowed), false for invalid,
and for active only on the link's own page — ignoring query strings, fragments
and trailing slashes.
const draft = isEnabled && previewSessionAllows(session, pathname)Before adopting it, know what it costs. The plugin cannot enforce this for you: only your app knows which of its fetchers is "the page the visitor is on", so the check has to be applied per fetcher rather than once.
- Server Components cannot read the request pathname. You either thread it
down from the route, or key the check off the identifier a fetcher already has
(a slug, a path). Whichever you choose has to agree exactly with
resolveUrl— locale prefixes, trailing slashes and all. - Composed content needs its own decision. A page built from other documents — related items, listings, cards — fetches them separately, and draft-aware fetches of those will show their drafts too. Turning that off keeps the preview tight but can render the page with holes, because a document that has never been published is invisible to an anonymous visitor.
- It fails silently. A path that does not match renders published content, which reads as "the preview link is broken" rather than as a denial.
In a large app that is a decision per draft-aware fetcher, not a one-line change. Weigh it against what the link actually is: an unguessable token, revocable at any time, sent deliberately to someone by an editor.
Options
| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| collections | string[] | — | Collections that get a "Share preview" button. Must have drafts. |
| resolveUrl | (args) => string \| Promise<string> | — | Document → public URL. Must return a root-relative path. |
| collectionSlug | string | 'preview-links' | Slug of the generated links collection. |
| adminGroup | string | — | admin.group for the links collection. |
| hideCollection | ({ user }) => boolean | always visible | admin.hidden for the links collection. |
| expiryOptions | { label, hours }[] | No expiry / 24 hours / 7 days / 30 days | hours: null means no expiry. |
| cookieName | string | 'payload-preview-share' | Name of the share cookie. |
| maxSessionHours | number | 168 | Caps the cookie even for never-expiring links. |
| redeemPath | string | '/api/preview/share' | Used when building link URLs. |
| overrides | (collection) => collection | — | Adjust the generated links collection (access, labels, extra fields). |
| log | boolean \| { slug, ip, userAgent, overrides } | off | Record each redemption in a separate collection. See below. |
resolveUrl receives { doc, collectionSlug, req }. It runs at redeem time, not
at mint time, so a slug edited after a link was sent still resolves correctly.
The links collection
| Field | Notes |
| --- | --- |
| token | Unique, indexed, read-only. Generated on create; never taken from the request. |
| doc | Polymorphic relationship over the configured collections. Labelled "Shared from": it records where the link was minted and where it lands, and is not a permission boundary — the session it grants is site-wide. |
| expiresAt | Optional. Empty means live until revoked. |
| revokedAt | Set to kill the link. Revoked links are kept, not deleted. |
| note | Optional reminder of who the link went to. |
| createdBy | Stamped from req.user. |
| lastUsedAt | Stamped on redeem. |
| firstUsedAt | Set on the first redemption, then left alone. |
| useCount | Redemptions, not page views. A link sent to one reviewer that has been opened many times has probably been passed on — sort the list by it and the outlier is at the top. |
The three usage stamps reject field-level updates, so only the redeem path writes them. They are evidence about a link rather than settings on it.
Access is restricted to users of the admin auth collection (admin.user, default
users) for all four operations; the collection is never public. Note this is
stricter than "any authenticated user" on purpose — in an app with a second auth
collection (customers, members), every row here hands out a token that unlocks
site-wide draft mode. The redeem path reads with overrideAccess: true.
Use overrides to tighten this further — for example, admin-only delete:
previewLinksPlugin({
// …
overrides: (collection) => ({
...collection,
access: { ...collection.access, delete: ({ req }) => req.user?.role === 'admin' },
}),
})Tokens are stored in plain text. The database already holds the content the token protects, so hashing buys nothing real and costs the ability to re-copy a link.
The sidebar drawer lists the links minted from the document you are on, plus a count of those live elsewhere with a link through to the collection. Since every live link reaches the whole site, an editor revoking "the link for this page" would otherwise leave others live that grant a recipient the same access.
A link's own edit view leads with the Share URL and a Copy button, so a
record opened from that list is directly usable. Payload exposes no slot for
removing a list view's "Create New" button, and /create is directly reachable
regardless, so the create view leads with a pointer to the drawer instead — a
link made by hand behaves identically.
Logging redemptions
Off by default. The rows describe people outside your organisation, so switching it on is a decision for the project rather than something that should happen because you installed a plugin.
previewLinksPlugin({
// …
log: true,
// or
log: { ip: 'hashed', slug: 'preview-link-uses', userAgent: true },
})useCount on the link already tells you whether a link was passed around.
This tells you the shape of it — how many distinct visitors, over what period,
and whether anyone is still opening a link you revoked last week.
| Option | Default | Notes |
| --- | --- | --- |
| slug | 'preview-link-uses' | Slug of the generated collection. |
| ip | 'truncated' | 'none', 'truncated' (/24 or /48), 'hashed' (HMAC with the Payload secret), 'full'. |
| userAgent | true | Store the browser's user-agent, capped at 255 characters. |
| overrides | — | Adjust the generated collection. |
Each row records the link, usedAt, an outcome and — subject to the options —
ip and userAgent. The link's own edit view lists them under Openings.
Three things worth knowing:
- Redemptions, not page views. One row per click of a share URL, not per request the recipient makes afterwards. Logging every request would mean a write on the hot path of every page, and unbounded growth.
- One opening, not one request. A browser preloading the address bar or a
mail security gateway checking the link fetches the URL before the recipient
does. Those arrive with
Sec-Purpose: prefetch(or the olderPurpose/X-Mozheaders) and are not counted, and a repeat within ten seconds is treated as the same opening. Both still get a valid session — a prerender the browser later activates has to carry the cookie. - A rejected row means the URL is still out there. Opening a link after it expired or was revoked is recorded rather than silently denied.
- An unknown token writes nothing. Rows are only ever written for a token that matched a real link, so nobody can inflate the collection by spraying the endpoint.
The default keeps no full address: a /24 still distinguishes one reviewer from a
dozen, which is the question the log exists to answer. 'hashed' is keyed with
your Payload secret, so values are comparable between rows but not reversible or
matchable against a precomputed table.
Retention
log: { retentionDays: 90 }Unset means the plugin never deletes anything — it will not imply a policy it
isn't keeping. Setting it registers a Payload task (preview-links-prune) with
its own schedule, defaulting to 03:00 daily on Payload's default queue.
Deletion runs in batches with a ceiling per run, so a table left unpruned for a
year can't blow a function's time limit on the first tick; the next run
continues.
The default queue is deliberate: a bare /api/payload-jobs/run dispatches only
that queue, so pointing a cron at it needs no query string. Set queue to put
the prune on its own queue and cron instead.
Something still has to run the queue. Declaring a schedule makes the task
eligible — Payload derives jobs.scheduling from it — but a scheduled job only
fires when something asks it to:
// vercel.json
{ "crons": [{ "path": "/api/payload-jobs/run", "schedule": "0 3 * * *" }] }GET /api/payload-jobs/run first dispatches any schedules that are due, then
runs the queue, so one cron covers every scheduled task you ever add. Vercel
sends Authorization: Bearer $CRON_SECRET, which jobs.access.run has to
accept:
jobs: {
access: {
run: ({ req }) => {
const secret = process.env.CRON_SECRET
if (secret && req.headers.get('authorization') === `Bearer ${secret}`) return true
return isAdmin({ req })
},
},
}On a long-lived server, jobs.autoRun does the same job without a cron. On
serverless it does nothing at all — the process exits and the timer goes with
it — so don't rely on it there.
No queue at all? Call the function directly from a script, a route, or whatever scheduler you have. The task is a thin wrapper around it:
import { prunePreviewLinkUses } from '@humaan/payload-preview-links'
await prunePreviewLinkUses({ collectionSlug: 'preview-link-uses', olderThanDays: 90, payload })Not this plugin's job
- Turning a document into a URL — that is
resolveUrl. - What draft mode means to your data fetchers (
draft/overrideAccessflags). - The on-page preview banner,
noindexheaders, and any host middleware.
Development
pnpm install
pnpm dev # standalone Payload admin + dev front end at :3000
pnpm test:int # unit + integration (vitest, mongodb-memory-server)
pnpm test:e2e # playwright
pnpm builddev/ is a self-contained Payload app with a drafts-enabled pages collection
and a minimal front end that reads draftMode(), so the whole flow is
exercisable without a host project.
To test against a real app, pnpm yalc:publish here and
npx yalc add @humaan/payload-preview-links there, then pnpm yalc:push after
each rebuild.
Releasing
Run the Version workflow (workflow_dispatch: patch/minor/major) → it opens a
version-bump PR → merge to main with the release label → Publish Package
builds and publishes to npm.
