the split you actually want
Next.js 15 gives you a very fast place to run tiny bits of code near the user, and that’s useful, I use it, we use it at steezr, and I’d keep using it for auth gating, locale selection, feature flag bucketing, bot filtering, cache key shaping, and request normalization. That category of work is ephemeral, it can be repeated without side effects, and if it fails you can usually fall back to a safer default. The trouble starts when teams see the low latency and decide that purchase flows, subscription mutations, document state transitions, credit deductions, or webhook writes belong there too.
They don’t.
Edge runtimes are a bad place for stateful business logic because your failure modes get weirder right when the stakes get higher. You’ll hit partial retries from clients, cold starts in odd regions, missing Node APIs, narrower database driver support, inconsistent tracing, and subtle differences between local dev and production that only show up under load. Then somebody adds a direct write to Postgres from an Edge handler because it “worked in staging”, your payment provider retries a callback, Vercel replays a request after a regional blip, and now you’ve got duplicated ledger entries with no clean trace tying the whole mess together.
The split-runtime pattern is straightforward. Keep Edge for cheap decisions made per request, then hand off any stateful command to a boring HTTP API written in Go 1.22, backed by PostgreSQL and Redis if you need short-lived dedupe or rate state. The handoff carries identity, request id, trace headers, tenant context, and an explicit idempotency key. The Go service owns transactions, retries, locking, durable queues, and schema evolution. You regain the things that matter once money, permissions, or irreversible state changes show up, correctness first, observability second, latency third.
That ordering matters more than people admit.
edge is for decisions
A good Edge function answers one question quickly, then gets out of the way. Can this user enter this route. Which experiment bucket should they see. Should this request hit a cache variant keyed by tenant and country. Does this bot need a challenge. Those operations are pure enough that repeat execution doesn’t hurt you, and they benefit from being close to the request ingress.
A bad Edge function opens a transaction, mutates two tables, calls Stripe, and tries to emit analytics after the response has already started. That pattern mixes slow I/O, multi-step failure handling, and side effects in an environment that was never built to be your system of record.
A clean middleware in Next.js 15 looks more like this:
1// middleware.ts2import { NextRequest, NextResponse } from 'next/server'3import { jwtVerify } from 'jose'45export const config = {6 matcher: ['/app/:path*', '/api/:path*'],7}89const secret = new TextEncoder().encode(process.env.JWT_PUBLIC_KEY_PEM)1011export async function middleware(req: NextRequest) {12 const reqId = req.headers.get('x-request-id') ?? crypto.randomUUID()13 const traceparent = req.headers.get('traceparent') ?? `00-${crypto.randomUUID().replace(/-/g, '')}-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}-01`1415 const auth = req.headers.get('authorization')16 let sub = ''17 let tenantId = ''1819 if (auth?.startsWith('Bearer ')) {20 const token = auth.slice(7)21 const { payload } = await jwtVerify(token, secret, {22 algorithms: ['RS256'],23 issuer: 'https://auth.example.com',24 audience: 'app',25 })26 sub = String(payload.sub ?? '')27 tenantId = String(payload.tid ?? '')28 }2930 const headers = new Headers(req.headers)31 headers.set('x-request-id', reqId)32 headers.set('traceparent', traceparent)33 if (sub) headers.set('x-auth-sub', sub)34 if (tenantId) headers.set('x-tenant-id', tenantId)3536 return NextResponse.next({ request: { headers } })37}
Notice what’s missing, no writes, no direct database access, no cross-system saga pretending to be a request handler. This code annotates, validates, and routes. That’s the job. If you keep Edge handlers that small, they stay legible, easy to test, and, more importantly, safe to execute twice.
go owns state
The Go 1.22 service is where commands land, because Go gives you the boring strengths you want for this class of work, stable concurrency, easy profiling, predictable memory behavior, a standard library that handles HTTP extremely well, and first-class support for context propagation and cancellation. Pair it with PostgreSQL 18 and you’ve got a setup that can survive retries and operator mistakes without turning every mutation into folklore.
I like explicit command endpoints instead of pretending every mutation is generic REST. A request to create an invoice, consume usage credits, approve a refund, or finalize an import should be modeled as a command with its own idempotency key and its own invariant checks. The payload gets validated before any transaction starts, the command is registered in an idempotency table, then the transaction executes exactly once from the caller’s point of view.
1// POST /v1/commands/consume-credits2package main34type ConsumeCreditsRequest struct {5 TenantID string `json:"tenant_id"`6 UserID string `json:"user_id"`7 ResourceID string `json:"resource_id"`8 Amount int64 `json:"amount"`9 IdempotencyKey string `json:"idempotency_key"`10}1112func (s *Server) ConsumeCredits(w http.ResponseWriter, r *http.Request) {13 ctx := r.Context()14 reqID := r.Header.Get("x-request-id")15 traceparent := r.Header.Get("traceparent")1617 var in ConsumeCreditsRequest18 if err := json.NewDecoder(r.Body).Decode(&in); err != nil {19 http.Error(w, "invalid json", http.StatusBadRequest)20 return21 }22 if in.Amount <= 0 || in.TenantID == "" || in.IdempotencyKey == "" {23 http.Error(w, "invalid command", http.StatusUnprocessableEntity)24 return25 }2627 res, err := s.Commands.ExecuteConsumeCredits(ctx, in, CommandMeta{28 RequestID: reqID,29 Traceparent: traceparent,30 ActorSub: r.Header.Get("x-auth-sub"),31 TenantID: r.Header.Get("x-tenant-id"),32 })33 if err != nil {34 s.writeCommandError(w, err)35 return36 }37 writeJSON(w, http.StatusAccepted, res)38}
Inside ExecuteConsumeCredits, use a single transaction, lock the account row with SELECT ... FOR UPDATE, record the command key in a table with a unique index on (tenant_id, command_type, idempotency_key), and store the final response body so repeats can return the same result shape. That’s migration-friendly because your command contract stays stable even if the schema under it changes twice in a quarter, which is exactly what happens in a growing SaaS.
This is the boring architecture, which is another way of saying it’s the architecture that survives contact with real users.
retries without corruption
Most teams say they want retries, what they usually have is duplicate execution. There’s a difference, and it shows up the first time a mobile client times out at 2.8 seconds while the server commits at 2.9, then the user taps again, then your queue processor also retries because it saw a 502 from an upstream proxy. Congratulations, three writes, one intention.
The fix starts with treating every state-changing request as an idempotent command. The client generates a stable key for one user intent, not one HTTP attempt. A good key looks like cmd_01HV7V7J8P6Y3ZK0JY9S2M4D6N, stored on the client through retries. The Edge layer must never mint a fresh key during a retry unless it has a durable reason to know this is a brand new intent.
On the Go side, persist command receipt before the side effect completes. A table like this works fine:
1create table command_dedup (2 tenant_id text not null,3 command_type text not null,4 idempotency_key text not null,5 status text not null,6 request_hash bytea not null,7 response_json jsonb,8 created_at timestamptz not null default now(),9 primary key (tenant_id, command_type, idempotency_key)10);
If the same key comes back with a different request hash, return 409 Conflict and be loud about it. Silent acceptance there is how data corruption gets normalized. If the same key comes back with the same hash and the command already finished, return the stored response. If it’s in progress, respond 202 Accepted with a polling location or a command status payload.
For upstream calls inside the command, retries need budgets and deadlines. Set them explicitly. A payment auth call might get one retry on a connect timeout with exponential backoff and jitter capped at 250 ms, because your p99 budget for the whole request is maybe 1.5 seconds, not an open invitation to keep spinning. In Go that usually means context.WithTimeout, a transport with sane idle and handshake timeouts, and retry logic that only fires on transient classes, never after a write where you can’t prove the remote side didn’t commit.
That last sentence is where people get burned.
trace the handoff
Observability falls apart at the runtime boundary unless you treat header propagation as part of the contract. A lot of teams have decent traces inside their Node app and decent traces inside their Go API, then no span linkage between them because someone forgot traceparent, or they generated a new request id on every hop and called it a day. That’s fake observability, dashboards full of color with no causal chain.
Propagate traceparent, tracestate if you use it, x-request-id, authenticated subject, tenant id, and an internal command id once it exists. Do not rely on ad hoc JSON logging alone. You want OpenTelemetry wired on both sides, exported to something like Grafana Tempo, Honeycomb, or Datadog APM, and you want logs to include the exact same ids.
A server-side call from a Next.js route handler to Go can be dead simple:
1const res = await fetch(`${process.env.API_URL}/v1/commands/consume-credits`, {2 method: 'POST',3 headers: {4 'content-type': 'application/json',5 'authorization': req.headers.get('authorization') ?? '',6 'x-request-id': req.headers.get('x-request-id') ?? crypto.randomUUID(),7 'traceparent': req.headers.get('traceparent') ?? '',8 'tracestate': req.headers.get('tracestate') ?? '',9 'x-auth-sub': req.headers.get('x-auth-sub') ?? '',10 'x-tenant-id': req.headers.get('x-tenant-id') ?? '',11 },12 body: JSON.stringify(command),13})
Then in Go, attach those values to the request context, start a child span, and log failures with the command key and tenant. If you ever have to answer “did we charge this customer twice”, you need to move from ingress request, to command record, to database transaction, to provider response without guessing. Guessing is what teams do before they have to issue credits manually on a Friday night.
One more thing, return machine-usable errors. 409 idempotency_key_reused_with_different_payload, 422 insufficient_credits, 503 upstream_timeout_retryable=true. Human-readable text is fine, the code matters more. Clients can build correct retry behavior from codes. They can’t build it from vibes.
migrations stop being scary
The nicest side effect of this split is that schema changes stop leaking into the request edge. If your Edge code is mostly reading claims, setting headers, and selecting execution paths, you can evolve tables and write models in the Go service without touching the globally distributed runtime every time product changes a billing rule.
Command contracts give you a buffer. Add a nullable column, deploy code that writes both old and new representations, backfill in the background, flip reads, then remove old paths later. Standard expand-contract work. The difference is that your public mutation surface stays stable because clients submit commands, not table-shaped blobs that mirror your current ORM mood.
I’ve seen this matter a lot on teams shipping fast. A Django monolith grows a few Next.js frontends, someone adds Edge handlers because the auth check was already there, then six months later those handlers are talking directly to three systems and nobody wants to touch a migration because they can’t see the blast radius. Split runtimes early and you avoid that trap. Keep the globally distributed layer stateless, keep the stateful rules in one service with one transactional store, and make every mutation replay-safe by design.
That doesn’t mean one giant Go service swallowing your whole stack. It means one clear place where correctness lives. We still build plenty of product surfaces with Next.js, HTMX, React Native, Django, whatever fits, and there’s nothing doctrinaire about it. The opinion is narrower than that. Stateful business logic should live where transactions, tracing, retries, and migrations are first-class concerns. Edge runtimes fail that test.
If your current setup already has money-moving code in Edge, I’d start with the highest-value commands first, subscription changes, balance mutations, import finalization, admin actions. Pull those behind an idempotent Go API, preserve headers, add real command records, and watch how many “we can’t reproduce it” bugs suddenly become ordinary, debuggable engineering.
