Custom · Next.js
This guide covers Next.js 14+ and Next.js 16 with the App Router. The pattern is:
- Server-only environment variables for
NEUROON_SHOP_IDandNEUROON_API_KEY. - Route Handler (
app/api/neuroon-token/route.ts) that signs the Widget Token locally and caches it. - Client
<Script>that loads the CDN loader withstrategy="afterInteractive".
Environment variables
# .env.local (NEVER commit)
NEUROON_SHOP_ID=shop_xxxxxxxx
NEUROON_API_KEY=sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
NEUROON_API_URL=https://api.neuroon.ai
All three lack the
NEXT_PUBLIC_prefix. They stay server-only and never reach the client bundle. The Shop API Key is used to sign the Widget Token locally (HMAC) and to authenticate server-to-server calls (/products/sync,/track/conversion).
Route Handler that signs the token
// app/api/neuroon-token/route.ts
import { NextResponse } from 'next/server';
import { createHmac } from 'node:crypto';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
let cache: { token: string; expiresAt: number } | null = null;
function signToken(): { token: string; expiresAt: number } {
const ts = Math.floor(Date.now() / 1000);
const payload = `${process.env.NEUROON_SHOP_ID}:${ts}`;
const sig = createHmac('sha256', process.env.NEUROON_API_KEY!).update(payload).digest('hex');
const raw = `${payload}:${sig}`;
const token = Buffer.from(raw, 'utf8').toString('base64url');
return { token, expiresAt: (ts + 24 * 3600) * 1000 };
}
export async function GET() {
const now = Date.now();
// Re-sign with a 5-minute margin before expiry
if (!cache || now >= cache.expiresAt - 5 * 60_000) {
cache = signToken();
}
return NextResponse.json({ token: cache.token, expiresAt: cache.expiresAt });
}
In serverless environments (Vercel) the
let cachedoes not survive cold starts. Use Redis (@upstash/redis) or Vercel KV for a cross-instance shared cache. See Recipe · Server-to-server token for the distributed variant.
Widget component (Client)
// app/components/NeuroonSearch.tsx
'use client';
import { useEffect, useState } from 'react';
import Script from 'next/script';
export function NeuroonSearch() {
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
fetch('/api/neuroon-token', { credentials: 'include' })
.then((r) => r.json())
.then((d) => setToken(d.token))
.catch(console.error);
}, []);
if (!token) {
return <div id="neuroon-search" aria-busy="true" />;
}
return (
<>
<div id="neuroon-search" />
<Script
src="https://cdn.neuroon.ai/widget@0.9.10/widget.js"
strategy="afterInteractive"
integrity="sha384-JTaG/IN0Jj/ImfUj2x5QVMG4HkbFHzui7fTpLtwl1hsP+kY9W8OODeSJRFWN1ZP5"
crossOrigin="anonymous"
data-token={token}
data-container="#neuroon-search"
data-theme="auto"
data-locale="en"
data-api-url={process.env.NEXT_PUBLIC_NEUROON_API_URL ?? 'https://api.neuroon.ai'}
/>
</>
);
}
Injection from the layout (RSC)
To print the token directly in the SSR HTML (better LCP, no flash):
// app/layout.tsx (Server Component)
import Script from 'next/script';
import { createHmac } from 'node:crypto';
let tokenCache: { token: string; expiresAt: number } | null = null;
function getWidgetToken(): string {
const now = Date.now();
if (tokenCache && now < tokenCache.expiresAt - 5 * 60_000) return tokenCache.token;
const ts = Math.floor(now / 1000);
const payload = `${process.env.NEUROON_SHOP_ID}:${ts}`;
const sig = createHmac('sha256', process.env.NEUROON_API_KEY!).update(payload).digest('hex');
const token = Buffer.from(`${payload}:${sig}`, 'utf8').toString('base64url');
tokenCache = { token, expiresAt: (ts + 24 * 3600) * 1000 };
return token;
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
const token = getWidgetToken();
return (
<html lang="en">
<body>
{children}
<div id="neuroon-search" />
<Script
src="https://cdn.neuroon.ai/widget@0.9.10/widget.js"
strategy="afterInteractive"
integrity="sha384-JTaG/IN0Jj/ImfUj2x5QVMG4HkbFHzui7fTpLtwl1hsP+kY9W8OODeSJRFWN1ZP5"
crossOrigin="anonymous"
data-token={token}
data-container="#neuroon-search"
data-theme="auto"
data-locale="en"
/>
</body>
</html>
);
}
The per-instance tokenCache ensures signing runs once every ~24 h. In multi-pod (Vercel) use Redis/KV — see Recipe · Server-to-server token.
Cart bridge in SPA
If your store manages the cart in client state (Zustand, Redux, Context), emit the event from an effect:
'use client';
import { useEffect } from 'react';
import { useCartStore } from '@/store/cart';
export function CartBridge() {
const cart = useCartStore((s) => s.snapshot());
useEffect(() => {
window.dispatchEvent(new CustomEvent('neuroon:cart-update', { detail: cart }));
}, [cart]);
return null;
}
Render <CartBridge /> in app/layout.tsx so it listens to global changes.
Conversion tracking
In Next.js, call the conversion endpoint from your "order confirmed" Route Handler:
// app/api/orders/[id]/confirm/route.ts
export async function POST(req: Request, { params }: { params: { id: string } }) {
const order = await markOrderAsPaid(params.id);
await fetch(`${process.env.NEUROON_API_URL}/api/plugin/shops/${process.env.NEUROON_SHOP_ID}/track/conversion`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shop-API-Key': process.env.NEUROON_API_KEY!,
},
body: JSON.stringify({
orderId: order.id,
orderValue: order.total,
currency: order.currency,
conversions: order.lines.map((l) => ({
productId: l.sku, quantity: l.quantity, lineTotal: l.unitPrice * l.quantity,
})),
}),
});
return Response.json({ ok: true });
}
Next steps
- Recipe · Next.js + React integration — guided step-by-step version.
server-to-server— generic token issuance pattern.- Widget · Configuration.