Skip to main content

Custom · Next.js

This guide covers Next.js 14+ and Next.js 16 with the App Router. The pattern is:

  1. Server-only environment variables for NEUROON_SHOP_ID and NEUROON_API_KEY.
  2. Route Handler (app/api/neuroon-token/route.ts) that requests the Widget Token from Neuroon and caches it.
  3. Client <Script> that loads the CDN loader with strategy="afterInteractive".

Environment variables

# .env.local (NEVER commit)
NEUROON_SHOP_ID=shop_xxxxxxxx
NEUROON_API_KEY=sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
NEUROON_API_URL=https://api.neuroon.ai

The first two do not carry the NEXT_PUBLIC_ prefix. They stay server-only and never reach the client bundle.

Route Handler for the token

// app/api/neuroon-token/route.ts
import { NextResponse } from 'next/server';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

// Widget Token TTL is fixed at 24 h. Rotate at 23 h.
const ROTATE_AFTER_MS = 23 * 60 * 60 * 1000;

let cache: { token: string; issuedAt: number } | null = null;

async function fetchToken(): Promise<{ token: string; issuedAt: number }> {
const res = await fetch(
`${process.env.NEUROON_API_URL}/api/shops/${process.env.NEUROON_SHOP_ID}/widget-token`,
{
method: 'POST',
headers: {
'X-Shop-API-Key': process.env.NEUROON_API_KEY!,
'User-Agent': 'nextjs-neuroon/1.0',
},
cache: 'no-store',
},
);
if (!res.ok) throw new Error(`Neuroon token issuance failed: ${res.status}`);
const body = (await res.json()) as { token: string };
return { token: body.token, issuedAt: Date.now() };
}

export async function GET() {
const now = Date.now();
if (!cache || now - cache.issuedAt >= ROTATE_AFTER_MS) {
cache = await fetchToken();
}
return NextResponse.json({ token: cache.token });
}

In serverless environments (Vercel) the let cache does not survive cold starts. Use Redis (@upstash/redis) or Vercel KV for a cross-instance shared cache.

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';

async function getWidgetToken(): Promise<string> {
const res = await fetch(
`${process.env.NEUROON_API_URL}/api/shops/${process.env.NEUROON_SHOP_ID}/widget-token`,
{
method: 'POST',
headers: { 'X-Shop-API-Key': process.env.NEUROON_API_KEY! },
next: { revalidate: 60 * 60 * 12 }, // 12 h ISR cache
},
);
const body = await res.json();
return body.token;
}

export default async function RootLayout({ children }: { children: React.ReactNode }) {
const token = await 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>
);
}

next: { revalidate: 60 * 60 * 12 } leverages Next.js' HTTP/ISR cache: the endpoint is hit only every 12 h per instance.

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