Skip to content

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.

8 min read

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.

Related questions

Can a Server Component call Sage directly?
Only if the Sage product in question is one of the hosted ones with a public interface. For the desktop and on-premise editions there is no address your deployed application can resolve, so the question is not whether it is good practice - the call cannot be made at all. Finding out which situation you are in is the first task on the project.
Can we not just open a port on their firewall?
You can ask, and the answer from most IT departments is no, correctly. An accounting server exposed to the internet is a liability with the whole ledger behind it. The usual arrangement is the reverse: something inside their network makes an outbound connection to you, which needs no inbound rule and is far easier to get approved.
How fresh will the data be?
As fresh as the last successful run of whatever is doing the syncing, which depends on a machine you do not administer. Minutes if it runs continuously and the server is healthy, a day if it runs overnight, longer than that whenever somebody reboots the server and forgets the service. Design the interface for the bad case rather than the good one.
Does this mean the project is not worth doing?
No, it means the value is in a different place than a live view. A read model built from an accounting system gives you fast pages, your own indexes, data you can join against the rest of your application and a system that stays up when theirs does not. Those are real, and none of them requires the number on screen to be a second old.

Back to all articles