Address autocomplete in a Next.js App Router form
1 Aug 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 exactly right for a publishable key (`waddr_pk_…`), which is designed to be visible and is locked to the domains you nominate — lifted onto someone else’s site, it stops working.
It is exactly 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 — this 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 exists for free text you already hold — a CSV import, a legacy record, 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, 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 — 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, and `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 pleasant 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 exactly those fields. Watch for `missing` on a unit: it means we could not corroborate one, which is a real answer when the matched record is a unit and the customer typed only the street number — 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, and we publish no accept/reject line on it on purpose — 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 actually 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, which is usually the point for 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.