Address autocomplete in a Next.js App Router form
1 Aug 2026 · updated 20 Oct 2026 · 6 min read
The App Router makes one address-form decision for you: autocomplete is interactive, so it lives in a client component. What is less obvious is which key belongs where, and how to verify an address on the server without being billed for the same lookup twice.
The field itself, in a client component
'use client';
import { AddressAutocomplete } from '@wattleaddr/react';
export function ShippingAddress({ onPick }: { onPick: (a: any) => void }) {
return (
<AddressAutocomplete
apiKey={process.env.NEXT_PUBLIC_WATTLEADDR_KEY!}
name="address"
placeholder="Start typing an address"
onSelect={(address) => onPick(address)}
/>
);
}Install it with `npm install @wattleaddr/react`. The component handles the request lifecycle: debouncing, ordering, and the session token that ties the keystrokes to the final selection.
Why the publishable key in NEXT_PUBLIC_ is fine
Anything named `NEXT_PUBLIC_*` is inlined into the client bundle at build time. That is fine for a publishable key (`waddr_pk_…`), which is designed to be visible and is locked to the domains you nominate. Copied onto someone else’s site, it stops working.
It is wrong for a secret key (`waddr_sk_…`). Never give one a `NEXT_PUBLIC_` name: the build bakes it into JavaScript that every visitor downloads, and rotating it afterwards means a redeploy. A secret key belongs in a server-only environment variable, read inside a route handler or a server action.
One session, one billable lookup
Autocomplete requests are not billed individually. A `session` token groups all the keystrokes for a single address search with the retrieve that follows, and the whole session counts as one lookup. The React component and the JS SDK manage the token for you. It matters mainly when you call the REST API directly, where forgetting it turns one address into a dozen billable events.
The practical rule: one session token per address the user is searching for. Start a new one when they clear the field and begin again.
Verifying on the server, without paying twice
Verify is for free text you already hold, such as a CSV import, a legacy record or an address pasted into a support ticket. It is not a second check on something the user just picked from the dropdown: that record came straight out of G-NAF and has already been paid for.
// app/api/verify-address/route.ts: server only, so the secret key never reaches the browser
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const { address } = await req.json();
const res = await fetch('https://api.wattleaddr.com.au/v1/addresses/verify', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.WATTLEADDR_SECRET_KEY}`, // no NEXT_PUBLIC_
'Content-Type': 'application/json',
},
body: JSON.stringify({ address }),
});
const data = await res.json();
return NextResponse.json({
verdict: data.verdict, // verified | corrected | ambiguous | unverified
address: data.match, // null when nothing matched
elements: data.elements, // per field: verified | changed | missing
changed: data.changed_elements, // e.g. ['postcode']: the field to re-prompt for
});
}Branch on the verdict, not on a score
A verify response tells you how well the match answers what you sent. `verdict` is the field to switch on. `verified` (everything you supplied agrees and no other candidate fits) and `corrected` (agrees as far as it goes, but you abbreviated or left something out) are safe to accept. `ambiguous` means an element was contradicted or part of your input had to be discarded to get a match. `unverified` means nothing usable came back. Accept the first two, send the third to review, and never auto-accept an ambiguous address.
The `elements` object is what makes this useful in a form. Each field (street number, street name, street type, suburb, state, postcode, unit) carries its own `verified` / `changed` / `missing` status, so when the postcode is the only thing that disagrees you re-prompt for the postcode, not the whole address. `changed_elements` lists those fields. Watch for `missing` on a unit: it means we could not corroborate one. That matters when the matched record is a unit and the customer typed only the street number, and it can be the difference between a delivered parcel and a returned one.
There is also a 0–100 `match_score` if you want to rank or triage a bulk import. We publish no accept/reject line on it, because the right threshold depends on what a wrong address costs you, and a single number cannot say "we matched a building on a street whose suburb you contradicted". The older top-level `confidence` field is deprecated: until August 2026 it returned "high" on any match at all, so anything branching on it was branching on a constant.
Handle "no match" as a real outcome
A verify that fails to match is not an error. The API returns 200 with `matched: false`, and it is not billed. Treat it as a branch in your form, not an exception: let the customer proceed with what they typed and flag the record for review. Blocking checkout because an address is missing from a quarterly dataset costs more than the bad row does.
The same applies to PO Boxes. They are not in G-NAF at all, so the API returns a `postal_address_unsupported` notice rather than pretending. Show it, and give people a separate field for a postal address.
Where the requests go
Both the browser calls and your server calls terminate on Australian infrastructure. Nothing is routed through a foreign CDN or an ad platform on the way. On 13 October 2026 a DNS change sent some requests through Cloudflare. The API was moved back within about an hour, and every record was DNS only by 14 October; see the Data Residency Attestation. That matters to anyone who has had to answer a data-residency question about their checkout.
The packages used above are the official ones; install notes and the headless client are in the npm section of the docs.