How to Connect Xero with Next.js?
Xero is the accounting platform of choice for a large number of small and medium-sized businesses, especially in the United Kingdom, Australia, and New Zealand. For SaaS products and internal tools built with Next.js, connecting to Xero means invoices can be created automatically, contacts stay in sync, and financial data flows without manual work. Next.js is a strong fit for this job, because the OAuth flow, API calls, and webhooks can all live in route handlers alongside the rest of the application. The main challenge is architectural: serverless functions don't keep anything in memory between requests, so tokens and connection details must be stored and refreshed correctly. In this guide, we'll connect Xero with the Next.js App Router step by step, with code examples for every stage.
Before You Start
Start by creating an app in the Xero developer portal. Choose a web app, and add your callback URL, for example http://localhost:3000/api/xero/callback for local development. The portal gives you a client ID and client secret. During development, you can connect to the Xero demo company, which comes with sample data and is ideal for testing without touching real books.
One recent change is important to know: apps created on or after 2 March 2026 can no longer request the broad accounting.transactions scope. Instead, they must request granular scopes such as accounting.invoices or accounting.payments, choosing only the permissions the app actually needs. Existing apps have until September 2027 to migrate. With that in mind, install the official Xero Node.js SDK and set up your environment variables.
npm install xero-node server-only# .env.local
XERO_CLIENT_ID=...
XERO_CLIENT_SECRET=...
XERO_REDIRECT_URI=http://localhost:3000/api/xero/callback
XERO_WEBHOOK_KEY=...
Configuring the Xero Client
The Xero SDK keeps tokens inside the client instance. That works in a long-running Node.js server, but in a serverless Next.js deployment each request may run in a fresh environment. The safe pattern is to create a new client for every request and load tokens from your database, rather than sharing a single global instance. The offline_access scope is required to receive a refresh token, which keeps the connection alive after the short-lived access token expires.
// lib/xero.ts
import "server-only";
import { XeroClient } from "xero-node";
// Create a fresh client per request: serverless functions share no memory
export function createXeroClient() {
return new XeroClient({
clientId: process.env.XERO_CLIENT_ID!,
clientSecret: process.env.XERO_CLIENT_SECRET!,
redirectUris: [process.env.XERO_REDIRECT_URI!],
scopes: [
"openid",
"profile",
"email",
"accounting.contacts",
"accounting.invoices",
"offline_access",
],
});
}
Implementing the OAuth 2.0 Flow
The authorization flow needs two route handlers. The first builds the Xero consent URL and redirects the user there. On the consent screen, the user signs in to Xero, reviews the requested permissions, and chooses which organisations to connect. In production, also include a state value in the client configuration and verify it in the callback to protect against cross-site request forgery.
// app/api/xero/connect/route.ts
import { NextResponse } from "next/server";
import { createXeroClient } from "@/lib/xero";
export async function GET() {
const xero = createXeroClient();
const consentUrl = await xero.buildConsentUrl();
return NextResponse.redirect(consentUrl);
}The second route handles the callback. It exchanges the authorization code for tokens, fetches the list of connected organisations, known as tenants, and stores the token set together with the tenant ID. Every Accounting API call must specify which tenant it targets, so storing this ID is essential.
// app/api/xero/callback/route.ts
import { NextResponse } from "next/server";
import { createXeroClient } from "@/lib/xero";
import { saveXeroConnection } from "@/lib/db";
export async function GET(req: Request) {
const xero = createXeroClient();
await xero.initialize();
const tokenSet = await xero.apiCallback(req.url);
await xero.updateTenants(false);
const tenant = xero.tenants[0];
await saveXeroConnection({
tenantId: tenant.tenantId,
tenantName: tenant.tenantName,
tokenSet,
});
return NextResponse.redirect(new URL("/settings/integrations", req.url));
}
Making API Calls and Refreshing Tokens
With a stored connection, any server-side code can call the Accounting API: route handlers, server actions, or background jobs. Before each call, load the token set, check whether the access token has expired, and refresh it if needed. Because refreshing issues a new refresh token, the updated token set must be saved immediately. The example below creates a draft sales invoice, which is a safe default until the business is ready to approve invoices automatically.
// lib/xero-invoices.ts
import "server-only";
import { Invoice, LineAmountTypes } from "xero-node";
import { createXeroClient } from "@/lib/xero";
import { getXeroConnection, saveXeroConnection } from "@/lib/db";
export async function createDraftInvoice(contactId: string, amount: number) {
const connection = await getXeroConnection();
const xero = createXeroClient();
await xero.initialize();
xero.setTokenSet(connection.tokenSet);
if (xero.readTokenSet().expired()) {
const tokenSet = await xero.refreshToken();
await saveXeroConnection({ ...connection, tokenSet });
}
const invoice: Invoice = {
type: Invoice.TypeEnum.ACCREC,
contact: { contactID: contactId },
lineAmountTypes: LineAmountTypes.Exclusive,
status: Invoice.StatusEnum.DRAFT,
lineItems: [
{
description: "Consulting services",
quantity: 1,
unitAmount: amount,
accountCode: "200",
},
],
};
const { body } = await xero.accountingApi.createInvoices(connection.tenantId, {
invoices: [invoice],
});
return body.invoices?.[0];
}
Receiving Xero Webhooks
Instead of repeatedly polling for changes, your application can subscribe to Xero webhooks for events such as created or updated contacts and invoices. Each webhook request carries an x-xero-signature header, an HMAC-SHA256 hash of the raw body created with your webhook key. When you first register the endpoint, Xero sends an intent-to-receive validation: your route must return 200 for correctly signed requests and 401 for incorrectly signed ones, or the subscription won't be activated.
Xero expects a fast response, so the handler should only verify the signature, queue the events, and return. Webhook payloads contain references to changed resources rather than full records, so a background job should then fetch the latest data from the API.
// app/api/webhooks/xero/route.ts
import crypto from "node:crypto";
export const runtime = "nodejs";
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get("x-xero-signature") ?? "";
const expected = crypto
.createHmac("sha256", process.env.XERO_WEBHOOK_KEY!)
.update(body)
.digest("base64");
const valid =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid) {
return new Response(null, { status: 401 });
}
const { events } = JSON.parse(body);
// Queue the events and respond quickly; fetch full records in a background job
await enqueueXeroEvents(events);
return new Response(null, { status: 200 });
}
Best Practices for Production
Xero enforces rate limits per organisation, including per-minute and daily call limits, as well as a limit on concurrent requests. Design your integration to fetch only records that changed, batch writes where the API allows it, and back off gracefully when a 429 response arrives. Background jobs are a better home for large syncs than request handlers, which may hit serverless execution time limits.
Treat tokens as sensitive credentials: store them encrypted, keep all Xero code server-only, and handle disconnections gracefully when a user revokes access from their Xero settings. Request only the granular scopes you need, since users are more likely to approve a focused permission list. Finally, log every sync and alert on failures so that problems surface immediately rather than at month-end.
Conclusion
Connecting Xero with Next.js gives your application direct, automated access to your customers' accounting data. By registering an app with granular scopes, implementing the OAuth 2.0 flow in route handlers, storing and refreshing tokens safely in a serverless environment, and verifying webhooks correctly, you build an integration that stays reliable as usage grows. Whether you're adding invoicing to a SaaS product or automating an internal finance workflow, getting the integration right is essential, and working with an experienced Next.js team that understands both Xero and software architecture ensures your financial data stays accurate and secure.