Skip to content

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.

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.

  1. 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.js from the Looku repository into your project as lib/looku.js (create the lib folder if you do not have one).

    If you cloned Looku next to your site, from your site’s folder:

    Terminal window
    mkdir -p lib
    cp ../looku/packages/next/src/index.js lib/looku.js

    If you cloned Looku somewhere else, replace ../looku with that folder.

  2. Create the proxy file in your project’s root folder (next to package.json; if your pages are in src/app, put it in src/). Which file depends on your Next.js version: look for "next" in package.json.

    If you already have a proxy or middleware file, add the import and the trackPageview(request, event); line to your function, and check that your matcher also leaves out api, _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(.*)" },
    ],
    },
    ],
    };

    The matcher decides 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 same missing list. 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" when lib is in the root, or "./lib/looku.js" when lib is in src/.

  3. Add the three settings for local testing. Create or open .env.local in the project root:

    LOOKU_ENDPOINT=https://looku.example.com
    LOOKU_SITE_ID=myblog
    LOOKU_SIGNING_KEY=your signing key

    Check that .env.local is in .gitignore (Next.js projects have it there by default), so that the key never ends up in git.

  4. Test it locally with a production build. The connector sends data only in production, so npm run dev sends nothing (on purpose: your own development would otherwise show up as visitors):

    Terminal window
    npm run build
    npm start

    Open http://localhost:3000 and a couple of pages, then open the Looku dashboard. Your visits should appear within a few seconds. If port 3000 is in use, start with npm start -- -p 3001 and open port 3001 instead. This sends real page views from your computer to your Looku; they are few and do no harm.

  5. 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_ID and LOOKU_SIGNING_KEY one 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.js and 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.

  6. Check. Open your live site on your phone, then the Looku dashboard. The visit should be there within a few seconds.

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

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.

  1. 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", …).)

  2. 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"],
    });
  3. Deploy, then open the page once in every browser you use, for example on your phone and your laptop.

  4. 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.

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.

  1. Pass the address on. In the proxy file, change the function like this (in middleware.js, the function is called middleware):

    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.

  2. Add a catch-all page. Create app/[...missing]/page.js (or, if you already have a catch-all page that calls notFound(), 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. after sends the message after the response, so nobody waits for it. Do not put these lines in app/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 to trackPageview, pass the same options to lookuRequestHeaders and trackNotFound.

    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.

  3. 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.

  • 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-Language and requests that claim a modern browser but lack its Sec-Fetch-Mode header. 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-country on Vercel, CF-IPCountry on 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.

About the author