Connect a Next.js site
What you get: every page request to your Next.js site sends a page view to Looku, from your site’s server. No script is added to your pages.
Time: 20 minutes. You need: Looku installed (Start here), and from the installation:
the receiver’s address (for example https://looku.example.com), your site id and the signing key.
How it works
Section titled “How it works”Next.js can run a small function before each page, called the proxy (in Next.js 15 and older, middleware). Looku’s connector runs there: it reads the request, builds a page view and sends it to Looku in the background after the response, so the page is not slowed down. Nothing changes in your pages.
-
Copy the connector into your project. The connector is one file with no dependencies. The npm package is not published yet, so copy
packages/next/src/index.jsfrom the Looku repository into your project aslib/looku.js(create thelibfolder if you do not have one).If you cloned Looku next to your site, from your site’s folder:
Terminal window mkdir -p libcp ../looku/packages/next/src/index.js lib/looku.jsIf you cloned Looku somewhere else, replace
../lookuwith that folder. -
Create the proxy file in your project’s root folder (next to
package.json; if your pages are insrc/app, put it insrc/). Which file depends on your Next.js version: look for"next"inpackage.json.If you already have a proxy or middleware file, add the
importand thetrackPageview(request, event);line to your function, and check that yourmatcheralso leaves outapi,_next, files with a dot and prefetches as below; otherwise images and prefetches are counted as page views.import { NextResponse } from "next/server";import { trackPageview } from "./lib/looku.js";export function proxy(request, event) {trackPageview(request, event);return NextResponse.next();}export const config = {matcher: [{source: "/((?!api|_next/static|_next/image|.*[.]).*)",missing: [{ type: "header", key: "next-router-prefetch" },{ type: "header", key: "purpose", value: "prefetch" },{ type: "header", key: "sec-purpose", value: "(.*)prefetch(.*)" },],},],};import { NextResponse } from "next/server";import { trackPageview } from "./lib/looku.js";export function middleware(request, event) {trackPageview(request, event);return NextResponse.next();}export const config = {matcher: [{source: "/((?!api|_next/static|_next/image|.*[.]).*)",missing: [{ type: "header", key: "next-router-prefetch" },{ type: "header", key: "purpose", value: "prefetch" },{ type: "header", key: "sec-purpose", value: "(.*)prefetch(.*)" },],},],};The
matcherdecides which requests run the proxy: pages, but not API routes, Next.js’s own files, files with a dot in the name (images,robots.txt) or prefetches (pages the browser loads in advance without the visitor opening them). Copy it as it is: the dot is written[.]on purpose, because\.in a JavaScript string becomes a plain dot that matches any character, and then only the front page would be counted.If you add entries of your own to the
matcher, for example for redirects, give each one the samemissinglist. Next.js removes the prefetch headers before the proxy runs, so only the matcher can leave prefetches out. An entry without it counts every link on the page that the browser loads in advance.If your proxy file is in
src/, the import is"../lib/looku.js"whenlibis in the root, or"./lib/looku.js"whenlibis insrc/. -
Add the three settings for local testing. Create or open
.env.localin the project root:LOOKU_ENDPOINT=https://looku.example.comLOOKU_SITE_ID=myblogLOOKU_SIGNING_KEY=your signing keyCheck that
.env.localis in.gitignore(Next.js projects have it there by default), so that the key never ends up in git. -
Test it locally with a production build. The connector sends data only in production, so
npm run devsends nothing (on purpose: your own development would otherwise show up as visitors):Terminal window npm run buildnpm startOpen
http://localhost:3000and a couple of pages, then open the Looku dashboard. Your visits should appear within a few seconds. If port 3000 is in use, start withnpm start -- -p 3001and open port 3001 instead. This sends real page views from your computer to your Looku; they are few and do no harm. -
Add the settings where the site runs.
In the Vercel dashboard, select your project and then Settings → Environment Variables (search: “Environment Variables”; checked against Vercel’s documentation in November 2026). Add
LOOKU_ENDPOINT,LOOKU_SITE_IDandLOOKU_SIGNING_KEYone at a time: Name, Value, and under environments choose only Production. Select Save after each.Settings apply only to new deployments, so redeploy: commit and push
lib/looku.jsand the proxy file, or in Deployments open the latest one and choose Redeploy. Preview deployments send nothing, even if you add the settings there too.Set the three settings as environment variables of the process that runs
next start, the same way you set your other settings (a.env.productionfile that is not in git, your systemd service, Dockerenvironment:or your host’s control panel)..env.localalso works:next startreads it, so on your own server one file is enough.NODE_ENVmust beproduction, whichnext startsets by itself. After adding or changing a setting, build and start again (npm run build, thennpm start): with older Next.js versions the middleware gets the values when it is built. -
Check. Open your live site on your phone, then the Looku dashboard. The visit should be there within a few seconds.
Options
Section titled “Options”Pass options as the third argument: trackPageview(request, event, { excludePaths: ["/admin"] }).
| Option | Default | What |
|---|---|---|
excludePaths |
[] |
paths not counted; a text path is excluded with everything under it ("/admin" also excludes /admin/users), and a regular expression (/^\/draft-/) is tested against the path |
optOutCookie |
none | { name: "looku_optout", value: "1" }: a browser that has this cookie with this value is not counted, for example your own, so that you do not count yourself. See Leave out your own visits. |
respectDoNotTrack |
true |
skip visitors who send Do Not Track or Global Privacy Control |
requireAcceptLanguage |
true |
skip requests without the Accept-Language header, which real browsers always send and many bots do not |
requireFetchMetadata |
true |
skip requests whose user agent names a browser that sends Sec-Fetch-Mode over HTTPS (Chromium 76, Firefox 90, Safari 16.4 and newer) when the header is missing. Crawlers that copy a browser’s user agent seldom send it |
enabled |
production only | true or false to override |
Leave out your own visits
Section titled “Leave out your own visits”Your own visits would make the numbers look busier than they are. The connector skips a browser that has an opt-out cookie, and a page on your own site sets that cookie with one tap.
-
Add the page. With the App Router, a route handler is enough. Pick a path that is hard to guess, so that visitors do not find it by chance:
app/looku-off-7h3k/route.js export function GET() {return new Response("Your visits from this browser are no longer counted.", {headers: {"Content-Type": "text/plain; charset=utf-8","Set-Cookie": "looku_optout=1; Path=/; Max-Age=34560000; SameSite=Lax; Secure; HttpOnly",},});}(With the Pages Router, an API route does the same with
res.setHeader("Set-Cookie", …).) -
Tell the connector about the cookie, and leave the page itself out:
trackPageview(request, event, {optOutCookie: { name: "looku_optout", value: "1" },excludePaths: ["/looku-off-7h3k"],}); -
Deploy, then open the page once in every browser you use, for example on your phone and your laptop.
-
Put the page in the dashboard. In Looku’s Settings, under Leave out your own visits, enter the full address (
https://example.com/looku-off-7h3k) and select Save. The bottom of the dashboard’s front page then has a link to the page for every site that has one, so on a new phone you open the dashboard and tap the link.
The cookie belongs to one browser. A private window forgets it, and browsers keep it for at most about 400 days, so open the link again after that or after clearing your cookies. The link is shown only to you when you are logged in.
Pages that do not exist
Section titled “Pages that do not exist”The proxy runs before Next.js knows whether a page exists, so a mistyped address, an old link or a bot trying
/wp-admin would be counted as a page. A catch-all page of your site can tell Looku that the page does not exist. Looku then
lists those page views under Missing pages on the dashboard and leaves them out of the other numbers. This needs
Next.js 15.1 or newer and the App Router.
-
Pass the address on. In the proxy file, change the function like this (in
middleware.js, the function is calledmiddleware):import { NextResponse } from "next/server";import { lookuRequestHeaders, trackPageview } from "./lib/looku.js";export async function proxy(request, event) {trackPageview(request, event);return NextResponse.next({ request: { headers: await lookuRequestHeaders(request) } });}A page does not see the address the way the proxy does, so the proxy passes the address in a request header. The header is signed with your signing key, so a visitor cannot use it to mark one of your real pages as missing.
-
Add a catch-all page. Create
app/[...missing]/page.js(or, if you already have a catch-all page that callsnotFound(), add the lines to it):import { headers } from "next/headers";import { notFound } from "next/navigation";import { after } from "next/server";import { trackNotFound } from "../../lib/looku.js";export default async function MissingPage() {const requestHeaders = await headers();after(() => trackNotFound(requestHeaders));notFound();}Next.js uses a catch-all page only for addresses that no other page matches, and
notFound()shows your own not-found page with status 404.aftersends the message after the response, so nobody waits for it. Do not put these lines inapp/not-found.js: reading the request headers there makes Next.js render every page of the site for each request instead of once at build time. If you pass options totrackPageview, pass the same options tolookuRequestHeadersandtrackNotFound.An address that matches a page with parameters, such as a blog post that does not exist, gets the not-found page without this page, so it stays under pages.
-
Deploy and check. Open a made-up address on your live site, for example
/looku-test-404, and then the dashboard. The address appears under Missing pages within a few seconds, and not under Top pages.
Looku keeps missing pages by day: on a day when the site answered an address with “not found”, every view of that address is counted as missing. If you add the page later, it is counted as a page from the next day on. Missing pages have a count only, no visitors, and they are kept as long as other data.
Page views from before you set this up stay where they are. To move them, give the addresses to pages:missing
(command line), for example
docker compose run --rm ingest pages:missing myblog /wp-admin /old-page.
Good to know
Section titled “Good to know”- Missing pages are counted as pages unless your site tells Looku about them; see Pages that do not exist.
- Client-side navigation is counted. When a visitor clicks a link, Next.js asks the server for the next page’s data, and the proxy runs for that request too. Prefetches are skipped by the matcher.
- Bots are skipped by their user agent, and so are requests without
Accept-Languageand requests that claim a modern browser but lack itsSec-Fetch-Modeheader. Looku also drops a visitor who loads more than 4 pages in three seconds, so click at a normal pace when you test. - The country comes from the platform:
x-vercel-ip-countryon Vercel,CF-IPCountryon Cloudflare. When the site is on Vercel behind Cloudflare, Vercel sees Cloudflare’s server, so the country is read from Cloudflare. Turn on IP Geolocation in Cloudflare (Network settings) if the country stays empty.
- Reading time and scroll depth from visitors who consent: Browser collector.
- Shop orders: Shop orders.
- Nothing arrives? No page views appear.