Next.js and Sage. There May Be Nothing to Call.
Several Sage products run on a machine in the customer's office with no public address. That is not an obstacle to the integration - it is the integration, and it decides what a page can show.
A brief arrives that says the customer portal should show each account's outstanding invoices, and the invoices are in Sage. It reads like a data-fetching task. Before it is one, there is a question to answer that decides the entire architecture, and the answer is frequently that there is no address to fetch from.
Sage is a family of products rather than a product. Some of them are hosted services with a public interface and behave like any other SaaS integration. Others are applications installed on a server in the customer's office, and their data is in a file on that server. A deployed Next.js application cannot reach that file, from a Server Component or from anywhere else, and no amount of the right library changes it.
Find out where it runs, before anything else
The first hour of the project is a phone call, not a schema.
Ask which Sage product, which edition, and where it is installed. Ask who administers that machine. Be ready for the answer to be uncertain, and be ready for "it's in the cloud" to mean that the finance team reaches a Windows desktop through a browser, which feels like the cloud to everyone using it and is not a public API.
There are only two outcomes and they are not variations of each other. Either there is an internet-facing interface, in which case you have an ordinary integration and the real work is where each piece of it should run. Or there is not, in which case the next section is the whole project.
The only shape that gets approved
When the system is inside somebody else's network, the connection is made from the inside out. Something running next to the Sage data - a service on their server, a scheduled export, a small connector - sends data outward over HTTPS to an endpoint you control. Nothing inbound is opened, which is why this is the version an IT department signs off on.
Your Next.js application is at the other end of that, and it is never the thing that calls Sage. It receives, or more usually it simply reads a database that the receiving process writes to.
Sage on their server
-> connector inside their network (outbound only)
-> ingest endpoint (validates, writes)
-> your database (the read model)
-> Server Components (render)
Drawing this in the first week matters more than it looks, because it is also the contract. Everything to the left of your database is a machine you do not control, running on a schedule you do not set, on a server somebody switches off at Christmas. Everything to the right is yours and can be made as fast and as reliable as you like.
Once the picture is on a page, the rest of the design stops being about Sage. It is a read model, and it is the ordinary problem of choosing a database and indexing it for the queries your pages make.
Staleness is data, not a cache setting
The mistake that follows is treating freshness as a caching question. It is not, and the distinction is worth being precise about because the vocabulary overlaps.
use cache with cacheLife controls how long Next.js holds onto a computed
result before recomputing it. That is a statement about your own rendering. It
says nothing whatsoever about how old the underlying figures are, because
recomputing reads the same database row the connector last wrote at half past
six this morning. Set the shortest cache life you like and the invoice total
does not get any newer.
So the age of the data has to be modelled as data. Every synced entity carries the timestamp of the run that produced it, and that column is not diagnostics - it is a field the interface renders:
export async function AccountBalance({ accountId }: { accountId: string }) {
const account = await db.account.findUnique({ where: { id: accountId } })
const age = Date.now() - account.syncedAt.getTime()
return (
<section>
<Amount value={account.balance} />
{age > 2 * 60 * 60 * 1000 ? (
<Warning>
Last updated {formatRelative(account.syncedAt)}. The accounting
system has not reported since then.
</Warning>
) : (
<Note>As of {formatTime(account.syncedAt)}</Note>
)}
</section>
)
}Two hours is not a magic number and you should pick your own from how often the connector is meant to run. The point is that the component has three states rather than two, and the third one - current, but old - is the state the system will actually spend its bad days in. A page that renders a fourteen hour old balance identically to a fresh one is not a caching bug. It is a design that never considered the case.
Some numbers should not be shown stale at all
Having decided that most figures can be shown with their age attached, decide separately which ones cannot.
An outstanding balance from this morning is useful and clearly labelled. A credit limit that governs whether an order can be placed is a different kind of number, and showing a stale one means accepting an order the finance team would have rejected. Contract pricing is the same. So is available stock, if somebody is about to promise it to a customer.
For those, the honest interface says the check cannot be performed right now and offers the next step - hold the order for review, or tell the customer somebody will confirm. It is a worse experience than a number, and it is better than a wrong one, and the finance team will tell you which fields belong in this category in about ten minutes if you ask them directly.
Writing back is a different promise
Reading is the easy direction. A form in your application that creates something on the other side is where the architecture gets tested, because you cannot confirm it.
The Server Action writes the request to your own database and returns. That is all it can truthfully do. Whether the record reaches Sage depends on a connector run that may be minutes away, and the action has no way to wait for it without holding a request open for a duration no platform allows.
Which means pending is a real state with its own row, its own screen and its own failure path, not a spinner. The user submits, sees that it has been queued, and sees it change to confirmed when a later sync reports back. If it fails validation on the Sage side - a customer account that does not exist, a nominal code that was renamed - that failure has to arrive somewhere a person looks, because there is no request left to return an error to.
Building this as optimistic UI and hoping is the single most common way these projects lose a week in the first month after launch. The write either happened or it did not, and the application is the only thing that can hold the difference.
What you get out of it
None of the above is a workaround for a limitation. A read model built this way is faster than any live call would have been, stays up when the accounting server does not, joins against the rest of your data, and can be indexed for the queries your pages actually make rather than the ones an accounting API was designed for.
What it costs is the honesty: the numbers have an age, the interface says so, and writes are requests rather than facts. Teams that build that in at the start ship a portal that people trust. Teams that add it after the first support ticket rewrite the components that matter most.
Our enterprise engagements are largely this shape, and the boundary between the application and the system of record is the part we scope first.
