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

@cachely-io/sdk

v0.11.6

Published

Universal JavaScript SDK for Cachely — provider-aware request classification, URL rewriting, and fetch adapter for routing your CMS API and assets through the edge. Includes `npx @cachely-io/sdk setup` for one-command project integration.

Downloads

2,563

Readme

@cachely-io/sdk

Universal JavaScript SDK for Cachely.

Pre-release (0.x). The package follows semver, so any minor version bump (0.9.x0.10.0) may include breaking changes. Pin a specific version in your package.json for production, e.g. "@cachely-io/sdk": "0.9.0" rather than "^0.9.0". The 1.0 release will commit to API stability — until then, treat this SDK as production-usable but not API-frozen.

Routes CMS API and asset requests through your Cachely tenant — adding edge caching, image optimization, and server-side token injection without changing your application logic.

Works in any JavaScript environment: Node.js 18+, browsers, Cloudflare Workers, Deno.

Built-in providers: Contentful, Prismic — plus any CMS via createGenericProvider


Install

Generic setup (recommended for most projects)

npx @cachely-io/sdk setup

Detects your framework and CMS, generates a small helper file, adds CACHELY_PROXY_URL to .env.example, and installs the SDK.

Useful flags:

  • --yes — non-interactive, accept all detected defaults
  • --dry-run — print every action without writing or installing
  • --tenant <slug> — bake your Cachely tenant slug into the helper
  • --proxy-url <url> — full proxy URL (e.g. https://my-project.cachely.io); also seeds .env.example
  • --provider <id>prismic · contentful · sanity · storyblok · shopify · generic
  • --framework <id>next · nuxt · vite · generic
  • --dir <path> — project root (default: cwd)

Generated helper locations:

  • Next: lib/cachely.ts
  • Nuxt: composables/useCachely.ts (or utils/cachely.ts if your project uses utils/)
  • Vite / generic: cachely.ts

Provider-specific onboarding (Prismic)

For a more guided, CMS-specific setup, use the provider commands. Currently, provider-specific onboarding is available for Prismic only.

# Prismic + Nuxt
npx @cachely-io/sdk@latest init prismic --tenant my-project
# or equivalently:
npx @cachely-io/sdk setup prismic --tenant my-project

Important distinction:

  • setup --provider prismic → generic helper-generation flow
  • setup prismic / init prismic → Prismic-specific onboarding (different outputs)

What Prismic onboarding does

  1. Detects your project — looks for nuxt.config.ts/js/mjs, @prismicio/client or @prismicio/nuxt in deps, and slicemachine.config.json.
  2. Reads repositoryName from slicemachine.config.json.
  3. Creates app/prismic/client.js wired to Cachely:
    import { createClient } from "@prismicio/client"
    import { createCachelyFetch } from "@cachely-io/sdk"
    import { repositoryName } from "~/slicemachine.config.json"
    
    const cachelyFetch = createCachelyFetch({
      tenant: "my-project",
      provider: "prismic"
    })
    
    export default createClient(repositoryName, {
      fetch: cachelyFetch
    })
  4. Patches config/nuxt/prismic.{js,ts} to add client: "~/app/prismic/client", preserving endpoint: config.repositoryName and any existing linkResolver.
  5. Writes/merges cachely.config.json:
    {
      "provider": "prismic",
      "framework": "nuxt",
      "repositoryName": "your-repo",
      "apiOrigin": "https://your-repo.cdn.prismic.io",
      "cachelyEndpoint": "https://my-project.cachely.io/~api/api/v2",
      "recommendedClient": "~/app/prismic/client"
    }
    cachely.config.json is a tooling manifest for the CLI and future tooling. Your Nuxt runtime does not import it.
  6. Installs @cachely-io/sdk if it's not already in package.json (app/prismic/client.js imports from it).
  7. No auth token needed — Prismic public repos require no access token.

Why a client file, not endpoint replacement?

Earlier versions rewrote the Prismic client to a long-form Cachely URL (https://<tenant>.cachely.io/~api/api/v2). That broke @nuxtjs/prismic and @prismicio/client@7+ with "A repository name is required for this method but one could not be inferred from the provided API endpoint."

clientConfig: { fetch: cachelyFetch } placed inline in the Nuxt prismic config also doesn't work: @nuxtjs/prismic reads clientConfig from useRuntimeConfig().public.prismic, and Nuxt strips functions when serializing runtimeConfig for SSR→client hydration. The fetch arrives at the runtime plugin as undefined.

The user-file path (client: "~/app/prismic/client") loads the client from a build-time virtual import, so the fetch function survives. This is the only mechanism that works for usePrismic().client — and it works without editing any pages.

After Prismic onboarding

rm -rf .nuxt
npm run dev

In the startup log, look for:

ℹ Using user-defined `client` at `~/app/prismic/client.js`

Then in DevTools → Network you should see requests like:

https://<tenant>.cachely.io/~api/api/v2/documents/search?...

not https://<repo>.cdn.prismic.io/api/v2/....

Asset URLs inside API JSON should be rewritten from https://images.prismic.io/... to:

https://<tenant>.cachely.io/<repositoryName>/...

Troubleshooting Nuxt + Prismic

  • Still seeing cdn.prismic.io: delete .nuxt, restart dev, and confirm config/nuxt/prismic.js contains client: "~/app/prismic/client".
  • Seeing "A repository name is required...": you used endpoint replacement. Keep the Prismic endpoint as the repository name and use app/prismic/client.js with createCachelyFetch.
  • Asset URLs still point at images.prismic.io: first verify API requests go through Cachely, then check the project's API URL rewrite setting and logs.

Dry-run mode

npx @cachely-io/sdk init prismic --dry-run --tenant my-project

Prints every planned file change without writing anything.

Provider-specific onboarding (Contentful)

npx @cachely-io/sdk setup contentful --tenant <slug>
# or equivalently:
npx @cachely-io/sdk init contentful --tenant <slug>

What Contentful onboarding does

  1. Confirms contentful is in your dependencies.
  2. Detects your framework and reads CTF_SPACE_ID (or spaceId / space) from config/contentfulConfig.json or contentfulConfig.json if present.
  3. Locates a Contentful client file under plugins/, services/, lib/, utils/, or src/{lib,services}/.
  4. If the file matches the simple safe shape (a single createClient(<bare-identifier>) call with no existing adapter: key), it is auto-patched to:
    • add const { createContentfulAdapter } = require('@cachely-io/sdk') (or the ESM import equivalent) after the last existing import,
    • introduce const cachelyAdapter = createContentfulAdapter({ tenant, spaceId }) above the config object,
    • add adapter: cachelyAdapter, inside the config object — preserving every existing key.
  5. Anything more custom (multiple createClient calls, an existing adapter: key, mixed CJS+ESM imports, single-line config objects) is left untouched and a manual-instructions block is printed instead.
  6. Writes/merges cachely.config.json with provider: "contentful", framework, spaceId, apiOrigin, previewApiOrigin.
  7. Installs @cachely-io/sdk if not already in dependencies.

The generated snippet uses the shipped createContentfulAdapter({ tenant, spaceId }) signature only. Preview vs. production routing is controlled by Contentful's existing host field (cdn.contentful.com vs preview.contentful.com); the adapter does not need a preview flag.

Provider-specific onboarding (Sanity, Storyblok — detect + status-only)

npx @cachely-io/sdk setup sanity     # also: init sanity
npx @cachely-io/sdk setup storyblok  # also: init storyblok

These two providers are intentionally detect + status-only in this release:

  • They detect the relevant dependencies and best-effort extract project metadata into cachely.config.json.
  • They do not modify any client file.
  • They do not install @cachely-io/sdk — there is no runtime snippet to import yet.
  • They do not print any executable copy-paste snippet, including no createCachelyFetch / createGenericProvider calls. The runtime registry currently resolves only provider: "contentful" and provider: "prismic"; calling createCachelyFetch({ provider: "sanity" }) or "storyblok" would throw Unknown provider. Until first-class Sanity/Storyblok routing ships in a future SDK release, the message points to docs and otherwise stays silent.

Re-running setup sanity / setup storyblok after a future SDK release will pick up runtime support automatically.

Listing available provider commands

npx @cachely-io/sdk init
# Please choose a provider: prismic, contentful, sanity, storyblok

Manual

npm install @cachely-io/sdk
# or: pnpm add @cachely-io/sdk
# or: yarn add @cachely-io/sdk
# or: bun add @cachely-io/sdk

Then write your own helper using createCachelyFetch / createCachelyUrlRewriter — see Quick start below.


How it works

Cachely sits between your app and your CMS. Instead of calling Contentful, Prismic, Sanity, or any other CMS directly, your requests go through your project's edge domain:

Your app
  → Cachely edge (https://my-project.cachely.io)
    → Contentful / Prismic / Sanity / ...

@cachely-io/sdk handles the URL rewriting at the transport layer. You configure it once and drop it into your CMS SDK — no other code changes needed.


Quick start

Contentful

import { createCachelyFetch } from '@cachely-io/sdk'
import { createClient } from 'contentful'

const cachelyFetch = createCachelyFetch({
  tenant: 'my-project',          // your Cachely project slug
  provider: 'contentful',
  providerConfig: {
    spaceId: 'abc123xyz',        // your Contentful space ID
  },
})

const client = createClient({
  space: 'abc123xyz',
  accessToken: '',               // leave empty — injected server-side by the proxy
  adapter: cachelyFetch,
})

// All API calls go through the proxy automatically:
// https://cdn.contentful.com/spaces/abc123xyz/entries
// → https://my-project.cachely.io/~api/spaces/abc123xyz/entries

Prismic

import { createCachelyFetch } from '@cachely-io/sdk'
import * as prismic from '@prismicio/client'

const cachelyFetch = createCachelyFetch({
  tenant: 'my-project',
  provider: 'prismic',
})

const client = prismic.createClient('my-repo', {
  fetch: cachelyFetch,
})

// All API calls go through the proxy automatically:
// https://my-repo.cdn.prismic.io/api/v2/documents/search?ref=...
// → https://my-project.cachely.io/~api/api/v2/documents/search?ref=...

Prismic + AI Transform

import { createCachelyFetch } from '@cachely-io/sdk'
import { createClient } from '@prismicio/client'
import { repositoryName } from '~/slicemachine.config.json'

const cachelyFetch = createCachelyFetch({
  tenant: 'my-project',
  provider: 'prismic',
  transform: 'czech',      // activates AI Transform profile "czech"
})

export default createClient(repositoryName, {
  fetch: cachelyFetch,
})

// API calls include the transform profile automatically:
// https://my-repo.cdn.prismic.io/api/v2/documents/search?ref=abc
// → https://my-project.cachely.io/~api/api/v2/documents/search?ref=abc&transform=czech
//
// The profile "czech" must match an ACTIVE AI Transform rule in your Cachely dashboard.
// Asset URLs are NOT affected — transform is only applied to /~api proxy requests.

Per-request transform with .with()

Need a different transform profile per route? Don't construct multiple clients — the fetch returned by createCachelyFetch is chainable.

const cachelyFetch = createCachelyFetch({
  tenant: 'my-project',
  provider: 'prismic',
})

// Scoped fetches — none mutate the original.
const czechFetch   = cachelyFetch.with({ transform: 'czech' })
const serbianFetch = cachelyFetch.with({ transform: 'serbian' })
const noTransform  = cachelyFetch.with({ transform: undefined })  // explicit no-op scope

// Pick one per route at request time:
const f = route.query.transform
  ? cachelyFetch.with({ transform: route.query.transform })
  : cachelyFetch

.with() always replaces the transform of the current scope. transform: undefined (or null, false, '') is an explicit "no transform" scope — it does not inherit the parent scope's value. All other base options (tenant, provider, providerConfig, enableApiProxy, enableAssetProxy, transformWhen) are preserved verbatim across .with().

Prismic adapter (per-request transform)

Skip the boilerplate of constructing a custom fetch — the Prismic adapter wraps @prismicio/client and lets you pick a transform profile per call.

import { createCachelyPrismicClient } from '@cachely-io/sdk/prismic'

const client = createCachelyPrismicClient({
  repositoryName: 'my-repo',
  tenant: 'my-project',
  transform: 'czech',   // optional default, overridden per-call below
})

const home = await client.getSingle('page_home', {
  fetchLinks: ['page.title', 'page.body'],
  cachely: { transform: route.query.transform ?? null },
  //                                          ^^^^^ null (or undefined / false)
  //                                                disables transform for this call
})

The cachely namespace is stripped before the params reach @prismicio/client, so it never appears in the outbound URL. If cachely is the only key in params, the params argument is dropped entirely. Underlying Prismic clients are cached per transform profile, so calling the same profile repeatedly does not recreate the client.

@prismicio/client is an optional peer dependency — install it in your app:

npm install @prismicio/client

customDomain — route through your Cachely-connected domain

By default every request goes to https://{tenant}.cachely.io/~api/.... When your Cachely project has a custom domain with the website proxy enabled, the proxy already serves your site — set customDomain: true to route requests through the current origin instead of the tenant subdomain:

const client = createCachelyPrismicClient({
  repositoryName: 'my-repo',
  tenant: 'my-project',
  customDomain: true,
})

Resolution (when no cachelyEndpoint is provided):

| Environment | Effective API origin | |---|---| | Browser, public hostname (https://my-site.com) | window.location.origin | | Browser, localhost / 127.x / [::1] / private LAN (10.x, 192.168.x, 172.16-31.x) / *.local | safe fallback: https://{tenant}.cachely.io | | SSR with NUXT_PUBLIC_SITE_URL or CACHELY_PUBLIC_SITE_URL set | that env value | | SSR without either env var | safe fallback: https://{tenant}.cachely.io | | customDomain: false (explicit) | https://{tenant}.cachely.io |

Why localhost falls back: in npm run dev your site is http://localhost:3011, which is not behind the Cachely Worker. The SDK would otherwise rewrite API requests to http://localhost:3011/~api/... and 404. The fallback keeps dev runs working with zero configuration.

Why SSR has its own fallback: Node's fetch rejects relative URLs with TypeError: Invalid URL. The SDK never produces a relative URL — if no per-request origin can be resolved, it uses the tenant domain, which always works.

Nuxt SSR: per-request origin via useRequestURL()

If you want SSR-rendered HTML to point at the custom domain (instead of the tenant fallback) without setting NUXT_PUBLIC_SITE_URL, pass a cachelyEndpoint resolver that reads useRequestURL(). The wizard generates this automatically for customDomain: true:

// app/prismic/client.js  (generated by `npx @cachely-io/sdk init prismic --custom-domain`)
import { createClient } from "@prismicio/client"
import { createCachelyFetch } from "@cachely-io/sdk"
import { repositoryName } from "~/slicemachine.config.json"

const cachelyFetch = createCachelyFetch({
  tenant: "my-project",
  provider: "prismic",
  customDomain: true,
  cachelyEndpoint: () => {
    // SSR: per-request origin from Nuxt's auto-imported useRequestURL().
    // Returning undefined hands control back to the SDK's customDomain logic.
    if (typeof window === "undefined") {
      try {
        const u = useRequestURL()
        return u?.origin
      } catch {
        return undefined
      }
    }
    return undefined
  },
})

export default createClient(repositoryName, { fetch: cachelyFetch })
cachelyEndpoint (explicit override)

Pass cachelyEndpoint to bypass everything above. Both forms are accepted — the SDK normalizes them to the origin, so the path never duplicates:

| Input | Effective base | |---|---| | 'https://my-site.com' | https://my-site.com | | 'https://my-site.com/~api/api/v2' | https://my-site.com (path stripped) | | () => 'https://my-site.com' | https://my-site.com | | () => undefined | falls through to customDomain logic |

const client = createCachelyPrismicClient({
  repositoryName: 'my-repo',
  tenant: 'my-project',
  cachelyEndpoint: 'https://my-site.com',
  // or function form for per-request resolution:
  // cachelyEndpoint: () => useRuntimeConfig().public.siteUrl,
})

delivery: "same-origin" is the legacy form, kept as a backward-compatible alias for customDomain: true. New code should use customDomain.

Older Contentful SDKs — use createCachelyFetch directly

The packaged createContentfulAdapter is shaped for the modern contentful JS SDK (11.x and similar Axios-adapter contracts). If you're integrating with an older Contentful SDK (e.g. the bundle named contentful.node.js shipped with Nuxt 2 / Vue 2 vintage projects) and run into adapter errors, fall back to passing createCachelyFetch directly:

import * as contentful from 'contentful'
import { createCachelyFetch } from '@cachely-io/sdk'

const cachelyFetch = createCachelyFetch({
  tenant: 'my-project',
  provider: 'contentful',
  providerConfig: { spaceId: 'abc123xyz' },
})

const client = contentful.createClient({
  space: 'abc123xyz',
  accessToken: 'proxy-injected', // any truthy string — real token is injected server-side
  // Older Contentful SDKs accept a custom fetch function instead of an adapter.
  // Some versions read fetch off `globalThis` instead — set it before calling createClient:
  //   globalThis.fetch = cachelyFetch
})

createCachelyFetch is a plain Fetch-API function and avoids the Axios/adapter shape entirely, so it dodges adapter contract mismatches in older SDK versions.

Any CMS via the generic provider

import { createGenericProvider, createCachelyFetch } from '@cachely-io/sdk'

const storyblok = createGenericProvider({
  id: 'storyblok',
  apiHosts: ['api.storyblok.com'],
  assetHosts: ['a.storyblok.com'],
})

const cachelyFetch = createCachelyFetch({
  tenant: 'my-project',
  provider: storyblok,           // pass ProviderDefinition directly — no registry needed
})

// https://api.storyblok.com/v2/stories → https://my-project.cachely.io/~api/v2/stories
// https://a.storyblok.com/f/123/hero.jpg → https://my-project.cachely.io/f/123/hero.jpg

URL rewriting without fetch

Use createCachelyUrlRewriter when you need to transform URLs without intercepting fetch — useful for server-side response body processing, logging, or building custom adapters.

import { createCachelyUrlRewriter } from '@cachely-io/sdk'

const rewrite = createCachelyUrlRewriter({
  tenant: 'my-project',
  provider: 'contentful',
  providerConfig: { spaceId: 'abc123xyz' },
})

// API URL
rewrite('https://cdn.contentful.com/spaces/abc123xyz/entries?locale=en')
// → { url: 'https://my-project.cachely.io/~api/spaces/abc123xyz/entries?locale=en', kind: 'api', rewritten: true }

// Asset URL — image optimization params are preserved
rewrite('https://images.ctfassets.net/abc123xyz/assetId/token/hero.jpg?w=1200&fm=webp')
// → { url: 'https://my-project.cachely.io/assetId/token/hero.jpg?w=1200&fm=webp', kind: 'asset', rewritten: true }

// Non-CMS URL — passes through unchanged
rewrite('https://fonts.googleapis.com/css2?family=Inter')
// → { url: 'https://fonts.googleapis.com/css2?family=Inter', kind: 'unknown', rewritten: false }

Response transformer

When you fetch a CMS response on the server and want to rewrite the asset URLs inside the response body (not just intercept fetch calls), use the response transformer. It walks any JSON-serializable value, replacing CMS asset hosts with your Cachely proxy base.

The transformer is part of @cachely-io/sdk — same one install.

Quick start

import { transformAssetUrls, getCachelyProxyBase } from '@cachely-io/sdk'

// CACHELY_PROXY_URL=https://my-project.cachely.io  (in your env)

const data = await fetch('https://cdn.contentful.com/spaces/abc/entries').then(r => r.json())

const transformed = transformAssetUrls(data, {
  cms: 'contentful',
  spaceId: 'abc',
  // proxyUrl read from CACHELY_PROXY_URL env var; pass `proxyUrl` to override
})

// Every Contentful asset URL inside `data` is rewritten to my-project.cachely.io

Per-CMS exports

import {
  transformPrismicAssetUrls,
  transformContentfulAssetUrls,
  transformSanityAssetUrls,
  transformShopifyAssetUrls,
  transformCloudinaryAssetUrls,
  transformImgixAssetUrls,
  transformGenericAssetUrls,
} from '@cachely-io/sdk'

// Prismic
const out = transformPrismicAssetUrls(data, {
  repository: 'my-repo',
  proxyUrl: 'https://my-project.cachely.io',
})

// Contentful
const out2 = transformContentfulAssetUrls(data, {
  spaceId: 'abc123xyz',
  proxyUrl: 'https://my-project.cachely.io',
})

getCachelyProxyBase(options)

Resolve the proxy base URL with the same precedence rules the transformer uses:

  1. options.proxyUrl (explicit override)
  2. process.env.CACHELY_PROXY_URL
  3. process.env.CMS_ASSETS_URL — temporary launch-window fallback for projects migrating from the legacy env var
import { getCachelyProxyBase } from '@cachely-io/sdk'

const base = getCachelyProxyBase()
// 'https://my-project.cachely.io/'  (with trailing slash, query/hash stripped)

Options reference

Every per-CMS function accepts the common shape plus its required keys:

type CommonOptions = {
  proxyUrl?: string                          // override the env-var resolution
  envVarName?: string                        // default: 'CACHELY_PROXY_URL'
  env?: Record<string, string | undefined>   // default: process.env
  nodeEnv?: string                           // default: process.env.NODE_ENV
  forceHttpsInProduction?: boolean           // default: true
  validateRequiredOptions?: boolean          // default: true
  throwOnError?: boolean                     // default: false
  transformers?: StringTransformer[]         // extra transformers run after the built-ins
  postTransform?: (parsed) => any            // optional final pass on the parsed object
  onError?: (err) => void                    // called when an error is swallowed
}

Required keys per CMS:

| cms | Required option | |---|---| | prismic | repository: string | | contentful | spaceId: string | | sanity | projectId: string (dataset?: string, default 'production') | | shopify | storeDomain: string | | cloudinary | cloudName: string | | imgix | imgixDomain: string | | generic | originUrl: string |


AI Experiments — client and SSR

Cachely's AI Experiments pipeline emits four headers on every proxied response (x-cachely-experiment-id, x-cachely-variant-id, x-cachely-variant-key, x-cachely-experiment-profile). The SDK ships a tracker that captures those headers, persists the assignment, and lets you fire conversion events.

Client-only flow (no SSR)

import {
  createCachelyExperimentTracker,
  createCachelyPrismicClient,
} from '@cachely-io/sdk/prismic'

export const experiments = createCachelyExperimentTracker({ tenant: 'my-project' })

export const prismic = createCachelyPrismicClient({
  repositoryName: 'my-repo',
  tenant: 'my-project',
  experiments, // wraps the underlying fetch — same response that renders content carries the assignment
})

// Later, in a browser-only place:
experiments.autoTrack() // attaches a delegated click handler for [data-cachely-track]
<button data-cachely-track="hero_cta_click" data-cachely-meta-cta="hero_primary">Get started</button>

For multi-experiment pages, add data-cachely-experiment-id="N" so the click is attributed to the right experiment.

SSR primitives — the framework-agnostic surface

When the response that carries the assignment is fetched on the server (e.g. inside Nuxt's useAsyncData, a Next.js RSC, or an Astro page), the browser never sees those headers directly. The SDK exposes four primitives that bridge the SSR boundary without making a second "probe" request:

| Primitive | Where it runs | What it does | |---|---|---| | tracker.dehydrate() | server | snapshot every captured assignment to a JSON-safe object | | serializeSnapshotScript(snapshot) | server | render an inert <script type="application/json"> tag for the HTML | | readSnapshotFromDom() | client | read the same tag back, returning the snapshot | | tracker.hydrate(snapshot) | client | merge the snapshot into the client tracker |

These four primitives are sufficient to integrate SSR experiments with any framework. Optional framework adapters (such as @cachely-io/sdk/nuxt below) are thin conveniences built on top of them — nothing in the core tracker knows about a specific framework.

Security: serializeSnapshotScript always escapes </<\/ and U+2028 / U+2029, and renders the script as type="application/json" (an inert data island that CSP script-src treats as data, not code).

Multi-experiment safety: the snapshot carries the full assignmentsById map. hydrate() is last-write-wins per experimentId and preserves assignments not present in the snapshot.

SSR recipe — generic (Express + template)

// server.ts
import {
  createCachelyExperimentTracker,
  serializeSnapshotScript,
} from '@cachely-io/sdk'
import { createCachelyPrismicClient } from '@cachely-io/sdk/prismic'

app.get('/', async (req, res) => {
  const experiments = createCachelyExperimentTracker({ tenant: 'my-project' })
  const prismic = createCachelyPrismicClient({
    repositoryName: 'my-repo', tenant: 'my-project', experiments,
  })
  const home = await prismic.getSingle('page_home')
  const { scriptTag } = serializeSnapshotScript(experiments.dehydrate())
  res.send(renderTemplate({ home, cachelyScript: scriptTag })) // inject before </body>
})
// client.ts (bundled separately)
import { createCachelyExperimentTracker, readSnapshotFromDom } from '@cachely-io/sdk'

const tracker = createCachelyExperimentTracker({ tenant: 'my-project' })
tracker.hydrate(readSnapshotFromDom())
tracker.autoTrack()

SSR recipe — Next.js App Router

// app/page.tsx (server component)
import { createCachelyExperimentTracker, serializeSnapshotScript } from '@cachely-io/sdk'
import { createCachelyPrismicClient } from '@cachely-io/sdk/prismic'
import { CachelyClientBoot } from './_components/CachelyClientBoot'

export default async function HomePage() {
  const experiments = createCachelyExperimentTracker({ tenant: 'my-project' })
  const prismic = createCachelyPrismicClient({
    repositoryName: 'my-repo', tenant: 'my-project', experiments,
  })
  const home = await prismic.getSingle('page_home')
  const { json } = serializeSnapshotScript(experiments.dehydrate())
  return (
    <>
      <HomeView data={home} />
      <script
        type="application/json"
        id="cachely-experiments"
        dangerouslySetInnerHTML={{ __html: json }}
      />
      <CachelyClientBoot />
    </>
  )
}
'use client'
// app/_components/CachelyClientBoot.tsx
import { useEffect } from 'react'
import { createCachelyExperimentTracker, readSnapshotFromDom } from '@cachely-io/sdk'

export function CachelyClientBoot() {
  useEffect(() => {
    const t = createCachelyExperimentTracker({ tenant: 'my-project' })
    t.hydrate(readSnapshotFromDom('cachely-experiments'))
    t.autoTrack()
  }, [])
  return null
}

SSR recipe — Astro

---
// src/pages/index.astro
import { createCachelyExperimentTracker, serializeSnapshotScript } from '@cachely-io/sdk'
import { createCachelyPrismicClient } from '@cachely-io/sdk/prismic'

const experiments = createCachelyExperimentTracker({ tenant: 'my-project' })
const prismic = createCachelyPrismicClient({
  repositoryName: 'my-repo', tenant: 'my-project', experiments,
})
const home = await prismic.getSingle('page_home')
const { scriptTag } = serializeSnapshotScript(experiments.dehydrate())
---

<Layout>
  <HomeView data={home} />
  <Fragment set:html={scriptTag} />
  <script>
    import { createCachelyExperimentTracker, readSnapshotFromDom } from '@cachely-io/sdk'
    const t = createCachelyExperimentTracker({ tenant: 'my-project' })
    t.hydrate(readSnapshotFromDom('cachely-experiments'))
    t.autoTrack()
  </script>
</Layout>

SSR recipe — Nuxt module (convenience adapter)

For Nuxt projects, install the auto-loading module instead of wiring dehydrate / hydrate by hand. Under the hood it calls the same four primitives.

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@cachely-io/sdk/nuxt'],
  cachely: { tenant: 'my-project' },
})
// composables/usePrismic.ts
import { createCachelyPrismicClient } from '@cachely-io/sdk/prismic'

export function usePrismicClient() {
  const experiments = useCachelyExperiments() // auto-imported by the module
  return createCachelyPrismicClient({
    repositoryName: 'my-repo', tenant: 'my-project', experiments,
  })
}
<!-- pages/index.vue — no SSR plumbing in product code -->
<script setup>
const prismic = usePrismicClient()
const { data } = await useAsyncData('home', () => prismic.getSingle('page_home'))
</script>

<template>
  <button data-cachely-track="hero_cta_click" data-cachely-meta-cta="hero_primary">
    Get started
  </button>
</template>

The module creates one tracker per request, dumps tracker.dehydrate() into useState('cachely:experiments') on app:rendered, and on the client reads the same useState, calls tracker.hydrate(...), then tracker.autoTrack(). No page-level boilerplate, no probe requests, no manual Headers/Response reconstruction. Opt out with cachely: { autoInstallPlugin: false } if you want to wire the primitives yourself.


API reference

createCachelyFetch(options)

Returns a fetch-compatible function that rewrites CMS URLs through the proxy. Safe to use as a global fetch replacement — non-CMS requests pass through unchanged.

const cachelyFetch = createCachelyFetch({
  tenant: 'my-project',          // required — Cachely project slug
  provider: 'contentful',        // required — provider id string OR a ProviderDefinition object
  providerConfig: {              // optional — provider-specific config
    spaceId: 'abc123xyz',        //   Contentful: spaceId
  },
  enableApiProxy: true,          // optional — rewrite API requests (default: true)
  enableAssetProxy: true,        // optional — rewrite asset requests (default: true)
  registry: myRegistry,          // optional — custom provider registry (used when provider is a string)
  transform: 'czech',            // optional — AI Transform profile name (see below)
})

AI Transform:

// Activate an AI Transform profile — appends ?transform=<profile> to all proxy API requests.
// The profile must match an ACTIVE AI Transform rule in your Cachely dashboard.
const cachelyFetch = createCachelyFetch({
  tenant: 'my-project',
  provider: 'prismic',
  transform: 'czech',    // activates AI Transform profile "czech"
})

// Pass false, null, or omit to disable (current behavior — no transform applied)
const cachelyFetch2 = createCachelyFetch({
  tenant: 'my-project',
  provider: 'prismic',
  transform: false,      // no AI transform
})

Note — profile must be active: transform must match a profile name with at least one active AI Transform rule in your Cachely dashboard. Requests with an unknown or inactive profile are served without transformation.

Note — separate from preview bypass: transform enables AI transformations on API responses. preview=1 is a separate parameter that bypasses API caching for draft content previews. The SDK never adds preview=1 automatically.

Per-request control with transformWhen:

// Skip transform for specific routes/queries — runs after the provider's
// own routing decision. Both must return true for transform= to be appended.
const cachelyFetch = createCachelyFetch({
  tenant: 'my-project',
  provider: 'prismic',
  transform: 'czech',
  transformWhen: ({ url, request }) => {
    // Example: never transform admin queries
    if (url.searchParams.get('admin') === '1') return false
    return true
  },
})

The transformWhen callback receives { url, request } where url is the original URL (before proxy rewrite) and request is the original Request object if one was passed (otherwise undefined). Return false to suppress the transform= parameter for that request.

Provider-specific safety — Prismic:

The Prismic provider knows that the root metadata endpoint (/api/v2) must never receive transform= — that endpoint returns the refs array used by @prismicio/client for all subsequent content queries. AI-transforming that response would break ref discovery and every page would fail with "refs is not iterable". The SDK applies transform= only to /documents/search requests for provider: 'prismic'.

This means you can drop the transform option in directly:

import { createClient } from '@prismicio/client'
import { createCachelyFetch } from '@cachely-io/sdk'

const cachelyFetch = createCachelyFetch({
  tenant: 'my-project',
  provider: 'prismic',
  transform: 'czech',
})

// Use the standard Prismic client — transform is applied transparently
// only to content queries, never to repository metadata or assets.
export default createClient(repositoryName, { fetch: cachelyFetch })

@prismicio/client calls (getSingle, getByUID, getAllByType, SliceZone, etc.) work unchanged. The Prismic predicate q=... is preserved byte-for-byte through the proxy — the SDK never re-encodes existing query parameters.

Selective proxying:

// Asset proxy only — API calls go directly to Contentful
const cachelyFetch = createCachelyFetch({
  tenant: 'my-project',
  provider: 'contentful',
  enableApiProxy: false,
})

// API proxy only — images are not proxied
const cachelyFetch = createCachelyFetch({
  tenant: 'my-project',
  provider: 'prismic',
  enableAssetProxy: false,
})

createCachelyUrlRewriter(options)

Returns a bound (url: string | URL) => RewriteResult function. Same options as createCachelyFetch.

import { createCachelyUrlRewriter } from '@cachely-io/sdk'

const rewrite = createCachelyUrlRewriter({
  tenant: 'my-project',
  provider: 'prismic',
})

const { url, kind, rewritten } = rewrite('https://images.prismic.io/my-repo/hero.jpg?w=800&fm=webp')
// url:       'https://my-project.cachely.io/my-repo/hero.jpg?w=800&fm=webp'
// kind:      'asset'
// rewritten: true

classifyUrl(url, provider)

Classify a URL as 'api', 'asset', or 'unknown' based on its hostname.

import { classifyUrl, contentful, prismic } from '@cachely-io/sdk'

classifyUrl('https://cdn.contentful.com/spaces/abc/entries', contentful)  // 'api'
classifyUrl('https://images.ctfassets.net/abc/assetId/token/img.jpg', contentful)  // 'asset'
classifyUrl('https://my-repo.cdn.prismic.io/api/v2/documents/search', prismic)  // 'api'
classifyUrl('https://images.prismic.io/my-repo/photo.jpg', prismic)  // 'asset'
classifyUrl('https://example.com/logo.svg', contentful)  // 'unknown'

rewriteUrl(url, kind, provider, tenant, config?)

Low-level rewrite — takes a pre-classified URL and returns a RewriteResult.

import { classifyUrl, rewriteUrl, contentful } from '@cachely-io/sdk'

const url = new URL('https://cdn.contentful.com/spaces/abc/entries?locale=en')
const kind = classifyUrl(url, contentful)
const result = rewriteUrl(url, kind, contentful, 'my-project', { spaceId: 'abc' })

// result.url       → 'https://my-project.cachely.io/~api/spaces/abc/entries?locale=en'
// result.kind      → 'api'
// result.rewritten → true

ProviderRegistry

Register custom or extended provider definitions.

import { ProviderRegistry, contentful, prismic, createCachelyFetch } from '@cachely-io/sdk'

const registry = new ProviderRegistry()
  .register(contentful)
  .register(prismic)

const cachelyFetch = createCachelyFetch({
  tenant: 'my-project',
  provider: 'contentful',
  registry,
})

The defaultRegistry is a shared singleton pre-populated with contentful and prismic. You only need a custom registry if you want to override a built-in provider or register your own.


createGenericProvider(config)

Create a ProviderDefinition from a config object — use any CMS or API origin without writing a custom provider from scratch.

import { createGenericProvider } from '@cachely-io/sdk'

const provider = createGenericProvider({
  id: 'storyblok',                        // unique provider id
  apiHosts: ['api.storyblok.com'],        // API hostnames (exact or wildcard)
  assetHosts: ['a.storyblok.com'],        // asset hostnames

  // Optional — stored for future Worker-side support, not yet processed by proxy
  auth: {
    header: 'Authorization',
    format: 'Bearer {token}',
  },
  previewBypass: {
    queryParams: ['preview', 'draft'],
  },
})

The returned ProviderDefinition plugs directly into createCachelyFetch, createCachelyUrlRewriter, classifyUrl, rewriteUrl, and ProviderRegistry.

URL rewriting follows standard Cachely conventions:

API:   https://api.storyblok.com/v2/stories → https://{tenant}.cachely.io/~api/v2/stories
Asset: https://a.storyblok.com/f/123/img.jpg → https://{tenant}.cachely.io/f/123/img.jpg

API_PREFIX

The /~api path prefix used by the Cachely API proxy.

import { API_PREFIX } from '@cachely-io/sdk'

console.log(API_PREFIX) // '/~api'

Providers

Contentful

| Kind | Hosts | |---|---| | API | cdn.contentful.com, preview.contentful.com | | Assets | images.ctfassets.net, videos.ctfassets.net, assets.ctfassets.net, downloads.ctfassets.net |

providerConfig.spaceId — Required for correct asset path stripping. When provided, the space ID segment is removed from the proxy URL path.

// Without spaceId:
// https://images.ctfassets.net/abc/assetId/token/img.jpg
// → https://my-project.cachely.io/abc/assetId/token/img.jpg

// With spaceId: 'abc':
// https://images.ctfassets.net/abc/assetId/token/img.jpg
// → https://my-project.cachely.io/assetId/token/img.jpg  ← spaceId stripped

Prismic

| Kind | Hosts | |---|---| | API | *.cdn.prismic.io (wildcard — matches any repo subdomain) | | Assets | images.prismic.io, prismic-io.imgix.net |

Prismic image optimization params (w, h, q, fm, auto, fit) are preserved end-to-end.

// Image optimization is preserved:
// https://images.prismic.io/my-repo/hero.jpg?auto=format,compress&w=920&fm=webp&q=80
// → https://my-project.cachely.io/my-repo/hero.jpg?auto=format,compress&w=920&fm=webp&q=80

Prismic's ref parameter changes on every content publish, making each published version a distinct cache key — no manual cache invalidation needed.


TypeScript

All types are exported from the package root:

import type {
  // Fetch / rewrite
  RequestKind,             // 'api' | 'asset' | 'unknown'
  ProviderDefinition,      // shape of a provider
  ProviderConfig,          // Record<string, string | undefined>
  RewriteResult,           // { url, kind, rewritten }
  CachelyFetchOptions,     // options for createCachelyFetch  (includes transform?, transformWhen?)
  UrlRewriterOptions,      // options for createCachelyUrlRewriter
  GenericProviderConfig,   // config shape for createGenericProvider
  TransformWhenFn,         // ({ url, request }) => boolean

  // Response transformer
  TransformOptions,         // shared base options
  PrismicTransformOptions,
  ContentfulTransformOptions,
  SanityTransformOptions,
  ShopifyTransformOptions,
  CloudinaryTransformOptions,
  ImgixTransformOptions,
  GenericTransformOptions,
  CmsDispatchOptions,       // discriminated union for transformAssetUrls
  SupportedCms,             // 'prismic' | 'contentful' | … | 'generic'
  StringTransformer,        // (jsonStr, ctx) => jsonStr
  TransformerContext,       // { base, nodeEnv }
} from '@cachely-io/sdk'

Custom providers

Using createGenericProvider (recommended)

The easiest way to add support for any CMS:

import { createGenericProvider, createCachelyFetch } from '@cachely-io/sdk'

const sanity = createGenericProvider({
  id: 'sanity',
  apiHosts: ['*.api.sanity.io'],
  assetHosts: ['cdn.sanity.io'],
  auth: { header: 'Authorization', format: 'Bearer {token}' },
})

// Pass directly — no registry needed
const cachelyFetch = createCachelyFetch({ tenant: 'my-project', provider: sanity })

// Or register and use by id string
import { defaultRegistry } from '@cachely-io/sdk'
defaultRegistry.register(sanity)
const cachelyFetch2 = createCachelyFetch({ tenant: 'my-project', provider: 'sanity' })

Using ProviderDefinition directly (advanced)

For full control over rewrite logic, implement the interface manually:

import { ProviderDefinition, defaultRegistry } from '@cachely-io/sdk'

const sanity: ProviderDefinition = {
  id: 'sanity',
  apiHosts: ['*.api.sanity.io'],
  assetHosts: ['cdn.sanity.io'],
  rewriteApiUrl(url, tenant) {
    return `https://${tenant}.cachely.io/~api${url.pathname}${url.search}`
  },
  rewriteAssetUrl(url, tenant) {
    return `https://${tenant}.cachely.io${url.pathname}${url.search}`
  },
}

defaultRegistry.register(sanity)

Requirements

  • Node.js 18+ (or any runtime with the Fetch API)
  • No dependencies