Skip to content

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.

7 min read

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 not

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

Related questions

Can the whole integration be Route Handlers?
You can write it that way and it will pass review. What it will not do is survive an outage at the other end, because nothing in the model retries. A Route Handler runs when a request arrives and stops when the response is sent, so any work that needs to happen later - a retry, a scheduled pull, a reconciliation pass - has nowhere to run.
Does after() give us a background job?
It gives you work that runs after the response is sent, which is not the same thing. The callback still runs inside the same invocation and inside the same maximum duration as the route, so it cannot outlive that route or be retried independently of it. It is the right tool for logging and the wrong one for anything you would be upset to lose.
Where should the tokens be stored?
In your own database, encrypted, keyed by the tenant they belong to. Not in module scope, because each function instance has a separate copy of it. Not in a cookie, because a background process has no request to read one from. Not in an environment variable, because it changes every half hour.
Is self-hosting a way around all of this?
It removes the instance-count problem, since one long-running Node process has one module scope and one memory. It does not remove the need for durable retries or scheduled work, and building those into your web process is how a deploy becomes a lost sync. The hosting model changes which problems you have, not how many.

Back to all articles