Where a Xero Integration Lives in a Next.js App
The OAuth callback belongs in a Route Handler. Almost nothing else does. A map of which parts of an accounting integration survive a serverless runtime and which quietly do not.
An accounting integration built into a Next.js application usually works on the first try in development and then behaves strangely in production, in ways that have nothing to do with the accounting system. The calls are correct. The credentials are correct. What is different is how many copies of the application exist and how long each one is allowed to live.
It is worth drawing the map before writing any of it, because the pieces that belong in a Next.js app and the pieces that do not are easy to tell apart once you know what to look for, and very hard to separate afterwards.
The part that genuinely belongs here
The redirect back from the consent screen is a Route Handler, and this is the one piece of the integration that Next.js is the natural home for.
// app/api/xero/callback/route.ts
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
export async function GET(request: Request) {
const params = new URL(request.url).searchParams
const jar = await cookies()
const expected = jar.get('xero_state')?.value
if (!expected || params.get('state') !== expected) {
redirect('/settings/accounting?error=state')
}
jar.delete('xero_state')
await exchangeCodeForTokens(params.get('code')!)
redirect('/settings/accounting?connected=1')
}It is short because it should be. The state value goes out in an httpOnly
cookie when you send the user to the consent screen and is compared on the way
back - without that comparison the endpoint will accept a code from anyone who
can get a browser to visit it. The code is exchanged on the server, the tokens
never enter a payload a Client Component could read, and the handler redirects
rather than rendering anything.
The authorisation URL is built in a Server Action or a Server Component, for the same reason: the client id is not secret but the flow is easier to reason about when only one side of the application ever constructs it.
That is the boundary. Everything below this line is where Next.js stops helping.
Module scope is not shared, and tokens are not static
The obvious optimisation is to hold the access token in a module-level variable so you are not reading the database on every render.
let cachedToken: string | null = null // do notIn development this is correct and fast. Deployed, each function instance has its own module scope, so that variable is not one cache, it is however many caches the platform currently has warm. This is the same structural fact that makes connection pooling behave unexpectedly
- separate memory per instance - but the consequence here is worse than an exhausted pool.
Xero's refresh token is single use. Refreshing gives you a new one and retires the one you sent. So if two instances independently notice an expired token and each refreshes, they are not duplicating a harmless request. They are racing over a credential that only one of them can end up holding, and the losing write can leave a dead token in your database with no error anywhere.
The fix is not a better cache. It is that the refresh happens in exactly one place, behind a lock that lives outside the process - a row lock in your database, or a key in Redis with a short expiry. Every instance reads the token from storage, and if it is expired it waits for whoever holds the lock rather than refreshing on its own.
Which raises the question of where that code should run, and the answer is that it should not be a Route Handler at all. A web request is the wrong place to hold a lock.
The webhook endpoint and the cold start
A Route Handler is the right shape for receiving a webhook, and the thing it must do is answer immediately.
Xero signs its deliveries with an HMAC over the raw body, so read the body as text and hash that text - anything that parses and re-serialises first produces a different string and a signature failure that looks exactly like a wrong key. The subscription is also activated by a validation call that has to be answered correctly before any real event arrives.
The delivery does not wait long. Neither does the platform, and a cold start is spent before your code runs at all. So the handler verifies, records the notification somewhere durable, and returns:
export async function POST(request: Request) {
const raw = await request.text()
if (!verify(raw, request.headers.get('x-xero-signature'))) {
return new Response(null, { status: 401 })
}
await enqueue(JSON.parse(raw).events) // durable, outside this process
return new Response(null, { status: 200 })
}after is tempting here and it is not the right tool. It runs the callback once
the response has been sent, but still inside the same invocation and inside the
same maximum duration as the route, which means it cannot be retried, cannot
outlive the request and is not guaranteed to have finished if the instance goes
away. For logging that is fine. For the only copy of an event you will ever be
sent, it is not.
What enqueue points at is genuinely outside the application: a queue, a
durable workflow, a row in a jobs table that a worker polls. The webhook payload
itself carries an id rather than the invoice, so something has to go and fetch
the record afterwards - and that fetch is subject to a rate limit, which means
it needs to be able to back off and try again later. None of those words
describe a Route Handler.
What the pages should read
Having got the data, the temptation is to call Xero from a Server Component so the page shows live figures. Resist it, and the reason is not caching strategy.
Every render of that page becomes a call against a per-tenant rate limit shared with your sync process. Every render inherits the other system's latency and its maintenance windows. And a 429 during a render is a page that fails, not a number that is briefly stale.
So Server Components read your own database, which is fast, always available and yours to index. How you cache that is then an ordinary decision about your own data rather than a negotiation with somebody else's API.
The one design question this leaves is honest labelling. The figures on screen are a copy, and the copy has an age. Store the timestamp of the last successful sync alongside the records and render it - "as of 14:20" costs one line and prevents the class of support ticket where somebody is looking at a number they believe is live. When the sync has been failing, say that on the page rather than only in an alerting channel.
The shape that works
Put the callback, the webhook receiver and the read path in the Next.js application. Put the token refresh, the scheduled pull, the retry logic and the reconciliation in something that runs on its own schedule and survives a deploy. That second thing is a small service, a queue worker, or a backend framework that already has all of it.
This is not a limitation to work around, and a Next.js application that talks to one external system this way talks to the fifth one the same way. It is the same conclusion as when not to reach for Next.js in the first place: when the difficulty is in the jobs rather than the render path, the front end is the second decision rather than the first.
If the accounting system is Sage instead, the map changes in one important respect, because for several Sage products there is nothing on the public internet to call and the whole integration moves behind a boundary your application cannot see past.
