@smalk/nextjs-ads
v1.2.3
Published
Smalk AI Ads — server-side ad injection for Next.js (App Router, Pages Router, middleware)
Maintainers
Readme
@smalk/nextjs-ads
Server-side Smalk ad injection for Next.js publisher sites.
- App Router (
<SmalkAd>Server Component) - Pages Router (
getSmalkAd()+<AdHtml>) - Middleware variant (
withSmalkAds()for raw<div smalk-ads>placeholders)
Compat: Next 13.4+, Node 18+.
Install
npm install @smalk/nextjs-adsSet two env vars:
SMALK_PROJECT_KEY=your-workspace-uuid
SMALK_API_KEY=your-api-key(SMALK_API_BASE is optional — defaults to https://api.smalk.ai/api/v1. The legacy SMALK_API_BASE_URL env var is also accepted as a fallback.)
Usage — App Router
// app/blog/[slug]/page.tsx
import { SmalkAd } from '@smalk/nextjs-ads/app';
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
return (
<article>
<h1>{slug}</h1>
<SmalkAd pathname={`/blog/${slug}`} />
</article>
);
}Combinatorial / query-string pages (/compare?cars=a,b,c)
If different ads are booked on the same path but different query (e.g. a
comparison page), you MUST forward the query via searchParams — otherwise every
variant collapses onto the path and serves the same ad. Pass the page's
searchParams straight through:
// app/compare/page.tsx
import { SmalkAd } from '@smalk/nextjs-ads/app';
export default async function Compare({
searchParams,
}: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
const sp = await searchParams;
return (
<article>
<h1>Comparison</h1>
<SmalkAd pathname="/compare" searchParams={sp} />
</article>
);
}The server canonicalizes the query (sorts list values, strips UTM/tracking
params), so ?cars=a,b,c and ?cars=c,b,a resolve the same booking and
?utm_source=x never shards the cache. The Pages Router getSmalkAd(ctx.req)
forwards the query automatically (it reads req.url).
## Usage — Pages Router
```tsx
// pages/blog/[slug].tsx
import { getSmalkAd, AdHtml } from '@smalk/nextjs-ads/pages';
export const getServerSideProps = async (ctx) => {
const ad = await getSmalkAd(ctx.req);
return { props: { ad } };
};
export default function Page({ ad }) {
return (
<article>
<h1>Title</h1>
<AdHtml ad={ad} />
</article>
);
}Usage — Middleware (raw <div smalk-ads>) — ⚠️ NOT for Next.js
withSmalkAds()does not work as a Next.js middleware. Next.js Edge middleware runs before the route renders, soNextResponse.next()is a passthrough sentinel with an empty body — there is no rendered HTML to inject into. Using it ships an empty<div smalk-ads>slot that AI crawlers see as no ad (it now alsoconsole.errors loudly at runtime).In Next.js, use
<SmalkAd>(App Router) orgetSmalkAd()(Pages Router).
withSmalkAds()is exported only for non-Next.js custom-server / reverse-proxy setups (e.g. an Express/Node front that holds the rendered HTML body). See Middleware variant — important caveat.
Caching
fetchdata cache: 20 minrevalidate(override with<SmalkAd revalidate={N} />)- Hash compare: when API returns a hash that differs from the last cached hash for that URL, the package calls
revalidatePath()to bust the Full Route Cache - HTTP: callers should not add CDN caching to ad-bearing pages (
Cache-Control: private, no-cache, must-revalidaterecommended at the edge)
Dynamic rendering — important
For ads to refresh between deploys, the route hosting <SmalkAd> must render dynamically (server-side per request), not be baked into the static build output.
The package handles this automatically: <SmalkAd> calls headers() (built-in Next 13.4+ opt-out) and unstable_noStore() (Next 14+ explicit opt-out). This makes the surrounding route dynamic by default.
You only need to act manually in two edge cases:
output: 'export'innext.config.js— full static export, no Node server at runtime. Server Components cannot run on requests; the ad HTML is frozen at build time. Not supported by this plugin. Removeoutput: 'export'(or switch the affected routes off it).Custom
dynamicexport overriding the heuristic — if yourpage.tsxor a parentlayout.tsxexportsexport const dynamic = 'force-static'or'error', that overrides the plugin's opt-out. Either remove that line, or explicitly mark the route as dynamic:// app/blog/layout.tsx (or page.tsx) export const dynamic = 'force-dynamic';Putting it on
layout.tsxis the lowest-friction option — it applies to every page under that folder without per-page edits.
If you're not sure, leave the plugin to handle it and check view-source of your deployed page: if you see <!-- smalk: no ad --> updating between deploys (and our dashboard registers a new AdPlacement after first request), you're good.
Freshness sweep
The /api/smalk/freshness route and freshness middleware bump Last-Modified headers on active-ad URLs to signal recency to AI crawlers.
- Active-URL cache: stored in the OS tempdir (
os.tmpdir()/smalk-active-ad-paths.json) by default so it works on serverless runtimes (Vercel, AWS Lambda, Cloudflare Pages Functions) where the project filesystem is read-only. For production serverless, swap in a persistent KV store — see "Persistent KV cache" below. - Per-instance + ephemeral (filesystem default): each Lambda / Vercel function instance keeps its own copy and the file evaporates when the instance is recycled. The middleware reloads from disk every 60 s and falls back to a fresh API fetch on cold start.
Last-Modifiedvalue: a UTC-day bucket (one bump per UTC day), notDate.now(). This keeps real users withIf-Modified-Sincegetting 304s within the day while still giving AI crawlers a fresh signal on the next day rollover.
Persistent KV cache
On serverless platforms (Vercel, Cloudflare Workers/Pages, AWS Lambda) the default os.tmpdir() file cache is per-instance and lost on cold start. Swap in a KV-backed store at app bootstrap so the active-URLs list survives across instances:
// app bootstrap (e.g. instrumentation.ts on Vercel, or top of middleware.ts)
import { setActiveUrlsStore, VercelKVActiveUrlsStore } from "@smalk/nextjs-ads";
setActiveUrlsStore(new VercelKVActiveUrlsStore());Built-in stores:
| Store | Use case | Requires |
|---|---|---|
| InMemoryActiveUrlsStore | Tests, single-process Node | — |
| FilesystemActiveUrlsStore | Local dev, long-running Node servers (default) | — |
| VercelKVActiveUrlsStore | Vercel deployments | @vercel/kv peer-installed by publisher + env vars |
| CloudflareKVActiveUrlsStore | Cloudflare Workers / Pages Functions | Pass the KVNamespace binding into the constructor |
// Cloudflare example — pass the binding from env
import { setActiveUrlsStore, CloudflareKVActiveUrlsStore } from "@smalk/nextjs-ads";
export default {
async fetch(req: Request, env: { SMALK_KV: KVNamespace }) {
setActiveUrlsStore(new CloudflareKVActiveUrlsStore(env.SMALK_KV));
// ... rest of handler
},
};For other KV backends (Upstash, Redis, DynamoDB, etc.), implement the ActiveUrlsStore interface (read() / write() returning { paths, updated_at }) and pass it to setActiveUrlsStore().
Legacy loadConfig() shape
loadConfig() is retained for pre-P2.5 publisher code that imported it from @smalk/nextjs-ads. It returns the canonical SmalkConfig plus an apiBaseUrl alias.
To preserve backward compatibility with callers that build URLs as ${cfg.apiBaseUrl}/api/v1/..., apiBaseUrl is derived from apiBase by stripping a trailing /api/vN segment:
| Source apiBase | loadConfig().apiBase | loadConfig().apiBaseUrl |
|-----------------------------------|-----------------------------------|-------------------------------|
| https://api.smalk.ai/api/v1 | https://api.smalk.ai/api/v1 | https://api.smalk.ai |
| http://localhost:8000/api/v1 | http://localhost:8000/api/v1 | http://localhost:8000 |
| https://custom.example.com | https://custom.example.com | https://custom.example.com |
New code should use getSmalkConfig().apiBase directly — it already includes the /api/v1 segment, so append endpoint paths like ${cfg.apiBase}/transform/ads/content/ without re-adding the version.
Trust Boundary
Ad HTML is rendered via React's raw-HTML escape hatch inside package-owned components only. The publisher never calls the unsafe React API directly. We do not ship a sanitizer (DOMPurify): ads include <script type="application/ld+json"> JSON-LD that AI crawlers parse for citation freshness, and the default DOMPurify config strips it. Smalk vets ad content server-side; this is the same trust model used by the WordPress (smalk-ai-ads-pro) and Drupal (smalk_d8) plugins.
Troubleshooting
curl -sA "Mozilla/5.0 ChatGPT-User/1.0" https://yoursite.com/blog/article | grep -iE 'smalk-ads|booking'If the response only contains <!-- smalk: no ad -->, either no booking is active or the API timed out (100 ms). Check publisher dashboard inventory status.
Middleware variant — important caveat
The withSmalkAds() helper is exposed for completeness but does not work as a Next.js middleware for HTML body transformation. Next.js Edge middleware runs BEFORE the route renders; NextResponse.next() is a sentinel and its body is empty. The helper is reusable in non-Next.js custom-server / Express-style setups (e.g., a Node fronting reverse proxy). For in-Next.js usage, prefer <SmalkAd> (App Router) or getSmalkAd() (Pages Router).
License
MIT
