npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@conapps/cms-preview

v0.1.0

Published

Redeem and verify content preview tickets from the platform CMS. Framework-agnostic, with a click-to-edit bridge and a conformance doctor.

Readme

@conapps/cms-preview

Redeem and verify content preview tickets from the platform CMS, so editors can look at unpublished content — at a chosen point in time, for a chosen audience — on your real site.

Framework-agnostic. Works with Nuxt, Next, Astro, SvelteKit, Express, or anything else that can read a query string and set a cookie.

npm install @conapps/cms-preview

The idea in one paragraph

The CMS never renders your site. It hands your site a signed context — which revision, at which instant, in which locale, for which audience — and your site renders itself with it. That is the whole contract. The ticket is an ES256 JWT verified against the CMS's public JWKS, so you hold no secret, and it authorises nothing: your server still fetches content with its own delivery token.

Three steps

1. Register a preview target

In the backoffice under Content → Settings → Preview: your origin, the path of your redemption route, and one or more URL patterns per content type (/{locale}/blog/{fields.slug}). No code.

2. Add the redemption route

// Nuxt: server/api/preview.get.ts
import { PreviewHandler } from '@conapps/cms-preview'

const handler = new PreviewHandler({
  cmsUrl:     process.env.CMS_URL!,          // https://cms.conapps.net
  issuer:     process.env.CMS_ISSUER!,       // usually the same
  siteOrigin: process.env.SITE_ORIGIN!,      // https://mymovement.app
})

export default defineEventHandler(async (event) => {
  const result = await handler.redeem(getRequestURL(event))
  if (!result.ok) throw createError({ statusCode: result.status, message: result.message })

  for (const [key, value] of Object.entries(result.headers)) appendResponseHeader(event, key, value)
  return sendRedirect(event, result.location, 302)
})

Next.js App Router:

// app/api/preview/route.ts
export async function GET(request: Request) {
  const result = await handler.redeem(request.url)
  if (!result.ok) return new Response(result.message, { status: result.status })
  return new Response(null, { status: 302, headers: { Location: result.location, ...result.headers } })
}

3. Pass the context to your content calls

const ticket = await handler.read(getRequestHeader(event, 'cookie'))
const params = ticket ? previewQueryParams(ticket.context) : {}

const entries = await $fetch(`${CMS_URL}/delivery/v1/spaces/${WS}/environments/master/entries`, {
  headers: { authorization: `Bearer ${await deliveryToken()}` },
  query: { content_type: 'blogPost', ...params },
})

The part that goes wrong

Preview must not read or fill your public cache. This is the failure that costs something: the first preview writes a draft into the cache, and every visitor gets the unpublished text until it expires. No error, no log.

Do not extend the cache key — bypass the cached handler:

export const contentHandler = (key: string, load: Loader) => {
  const cached = defineCachedEventHandler((e) => load(e, null), { maxAge: 300, swr: true, name: key })
  return defineEventHandler(async (event) => {
    const ticket = await handler.read(getRequestHeader(event, 'cookie'))
    if (!ticket) return cached(event)                       // public path, unchanged
    for (const [k, v] of Object.entries(previewResponseHeaders())) setResponseHeader(event, k, v)
    return load(event, ticket.context)                      // preview: never cached
  })
}

A key that includes the context is correct only as long as it is correct, and the field people forget is the audience.

Checklist

Run it instead of reading it:

npx cms-preview doctor --site https://mymovement.app --cms https://cms.conapps.net --ticket <jwt>
  • [ ] Ticket verified against JWKS, with issuer and audience
  • [ ] redirect restricted to site-local paths (no //, no scheme)
  • [ ] Cookie is HttpOnly, Secure, SameSite=None
  • [ ] Preview responses send private, no-store and X-Robots-Tag: noindex
  • [ ] Preview bypasses the route cache — neither reads nor fills it
  • [ ] The rendered page is uncacheable in preview, not just the content endpoints
  • [ ] Internal fetches forward the request's cookies (see below)
  • [ ] A separate content client for preview, with caching off
  • [ ] content-management:preview on a separate API integration, not the public one
  • [ ] Content-Security-Policy: frame-ancestors <backoffice-origin>

Two of these are worth spelling out:

SameSite=None — the preview runs in an iframe on the backoffice's origin, which makes every request cross-site. With Lax it works when you open the page in a tab and is empty in the frame.

A separate integration for the preview scope — if your public SSR client carries content-management:preview, one bug turns drafts public. Without the scope the Delivery API silently ignores ?preview=true, and the same bug is harmless. The scope is additive: it widens an already read-authorised request, so the integration needs content-management:read as well. On its own it produces a 403 on every call.

Your framework's internal fetch probably drops the cookie. If your pages call your own API routes (/api/content/...), check whether that call forwards the incoming request's headers during SSR. Nuxt's $fetch does not — you need useRequestFetch(). Next's fetch in a server component does not either; pass headers() through. Without it the preview context never reaches the code that would act on it, and the site quietly renders published content.

The page itself must be uncacheable too. Marking the content endpoints no-store is not enough: the rendered HTML usually goes out with no Cache-Control at all, and browsers then cache it heuristically. The symptom is the worst kind — an edit shows up sometimes and not others, while the server is answering correctly every time. A one-line server middleware that sets private, no-store whenever the preview cookie is present covers every route at once.

Click-to-edit (optional)

Ask the Delivery API for ?sourceMap=true, put the entries on your elements, and start the bridge in preview mode only:

import { startPreviewBridge } from '@conapps/cms-preview/bridge'
startPreviewBridge({ backofficeOrigin: 'https://app.conapps.net' })
<h1 :data-cms-source="JSON.stringify(entry.sys.sourceMap['fields.title'])">{{ title }}</h1>

A parallel map rather than invisible characters embedded in your strings: markers in the text survive copy-paste, break === and length, and end up in search indexes.

API

| Export | Purpose | |---|---| | PreviewHandler | redeem(url) and read(cookieHeader) — the whole integration | | PreviewVerifier | Ticket verification on its own | | previewQueryParams(ctx) | Context → Delivery API query parameters | | buildPreviewCookie / readCookie | Cookie with the right flags | | isSafeRedirect / safeRedirect | Open-redirect guard | | previewResponseHeaders() | no-store + noindex | | runDoctor(options) | The checklist, programmatically | | startPreviewBridge (/bridge) | Click-to-edit overlay |

Licence

MIT