draw the boundary early
Most teams get into trouble with edge runtimes for the same reason they get into trouble with microservices, they start with the deployment shape instead of the failure modes. Next.js 15 makes it very easy to sprinkle logic into middleware, route handlers, server components, and edge functions until the app feels fast in a demo and impossible to reason about six months later, then somebody asks where entitlement checks live, why tax calculation runs twice, and why a cache key depends on a value only the browser can see.
My rule is blunt because soft rules get ignored. Edge code exists to decide how a request should be delivered. Go services exist to decide what the system means. If a piece of logic changes the meaning of an order, subscription, approval, invoice, claim, report, or user capability, it belongs in Go. If a piece of logic decides which variant to render, which upstream to call, whether a signed cookie is valid enough to continue, or how to attach cache metadata and headers, edge is fine.
That boundary survives stress. A Next.js edge function can cheaply parse a signed session cookie, reject obviously invalid traffic, attach an x-request-id, compute a coarse feature bucket, and choose whether the response should stream a dashboard shell or redirect to /login. The moment that same function needs database-backed permission expansion, money math, transactional writes, webhook verification secrets, retry semantics, or a call that might take 800 ms on a bad day, you're already holding the wrong tool.
We've used this split at steezr on customer portals and internal systems where the web tier needed to feel instant, while the actual domain work sat behind Go APIs and workers. The teams that keep the edge thin ship faster. The teams that let domain logic leak upward spend their time diffing behavior between middleware and backend handlers, which is one of those self-inflicted problems that somehow still gets sold as architecture.
what edge should own
Next.js 15 is excellent at request shaping. Keep it there. The edge tier should own four things, response composition, session presence checks, cache segmentation, and user-adjacent personalization that can tolerate staleness or coarse granularity.
Response composition means streaming SSR, route selection, redirects, rewrites, partial page assembly, and choosing which backend endpoints to call for the first paint. A dashboard request can fan out to /api/me/summary and /api/org/current in parallel, render the shell immediately, then stream slower panels later with React suspense. That work belongs close to the request because it improves perceived latency without duplicating domain decisions.
Session checks at the edge should stay intentionally dumb. Verify a signed cookie, confirm expiry, maybe extract sub, org_hint, session_id, roles_hint, then pass those as headers to Go only after signature verification succeeds. If the cookie is missing or malformed, redirect. If it's valid, continue. Do not expand roles from five sources, do not infer billing state, do not evaluate plan limits there.
A simple middleware sketch looks like this:
1import { NextRequest, NextResponse } from 'next/server'2import { jwtVerify } from 'jose'34const secret = new TextEncoder().encode(process.env.SESSION_PUBLIC_VERIFY_KEY)56export async function middleware(req: NextRequest) {7 const token = req.cookies.get('session')?.value8 const rid = crypto.randomUUID()910 if (!token) {11 const res = NextResponse.redirect(new URL('/login', req.url))12 res.headers.set('x-request-id', rid)13 return res14 }1516 try {17 const { payload } = await jwtVerify(token, secret, { algorithms: ['EdDSA'] })18 const headers = new Headers(req.headers)19 headers.set('x-request-id', rid)20 headers.set('x-session-sub', String(payload.sub))21 headers.set('x-session-id', String(payload.sid))22 headers.set('x-org-hint', String(payload.org_hint ?? ''))23 return NextResponse.next({ request: { headers } })24 } catch {25 const res = NextResponse.redirect(new URL('/login', req.url))26 res.cookies.delete('session')27 res.headers.set('x-request-id', rid)28 return res29 }30}3132export const config = {33 matcher: ['/app/:path*']34}
That is enough. Notice what's absent, no database call, no feature evaluation with ten targeting rules, no secrets for downstream systems, no billing checks. If you need those, call Go.
what Go must own
Go 1.22 services should own every decision you would hate to debug in two places. Business rules, idempotency, SQL transactions, external integrations, queue-backed jobs, document processing, webhook ingestion, audit trails, permission expansion, and every secret that could hurt you if it leaks into the wrong runtime.
This sounds obvious until someone puts plan enforcement into a server component because it was easier to gate a button there. Then a mobile client hits the Go API directly, the rule isn't enforced, and now you've got two interpretations of the product. Same story with entitlements, discount rules, approval thresholds, export limits, KYC state, or any workflow that changes persisted data. The backend needs to be the single executable spec.
Go is a better fit for this for boring reasons, which is the highest compliment in infrastructure. Long-lived processes, stable memory behavior, straightforward concurrency, good p99s under load, first-class SQL drivers, easy worker processes, sane tracing, and no confusion about which Web APIs are missing in which runtime. A transaction in Go is still a transaction, not a debate about whether your runtime can open a TCP socket today.
A standard write path should look like this, Next.js submits intent, Go authenticates the session context again from trusted headers or the original token, loads authoritative user state, checks domain invariants, executes a transaction, emits an outbox event, returns a compact result. No UI-specific branches in the service. No HTML in the service either, unless you're building a dedicated BFF endpoint on purpose.
A Go handler for an order placement path should feel painfully ordinary:
1func (h *Handler) PlaceOrder(w http.ResponseWriter, r *http.Request) {2 ctx := r.Context()3 sess, err := auth.SessionFromRequest(r)4 if err != nil {5 http.Error(w, "unauthorized", http.StatusUnauthorized)6 return7 }89 var cmd PlaceOrderCommand10 if err := json.NewDecoder(r.Body).Decode(&cmd); err != nil {11 http.Error(w, "invalid json", http.StatusBadRequest)12 return13 }1415 out, err := h.App.PlaceOrder(ctx, sess.Subject, cmd)16 if err != nil {17 h.Errors.Write(w, err)18 return19 }2021 writeJSON(w, http.StatusCreated, out)22}
Nothing fancy. Good. Fancy is where duplication sneaks in.
auth without drift
The fastest way to rot this architecture is letting the edge and backend disagree about identity. One signed cookie, one issuer, one key rotation story, one authoritative claim set. Everything else is derived.
I prefer an opaque session ID in the cookie for high-control systems, backed by Redis or PostgreSQL, because revocation is trivial and claim drift disappears. I also accept a signed JWT session cookie when latency matters and the claims are intentionally small. In both cases, edge only validates enough to gate delivery. Go validates for authority. Treat edge validation as advisory, useful for shaping traffic, useless as your final security boundary.
For JWT cookies, keep claims sparse: sub, sid, exp, iat, maybe org_hint. Skip the temptation to stuff ten roles and twenty flags into it. That turns every role change into a cookie refresh problem and quietly teaches product engineers to trust stale claims. If you need current permissions, Go loads them.
The request flow I recommend is simple. Browser sends session cookie to Next.js. Middleware verifies signature and expiry, sets x-request-id, forwards the original cookie to Go on server-side fetches, and may attach a few verified hint headers for logs. Go ignores any untrusted identity headers from the public internet, revalidates the cookie or resolves the opaque session ID, then computes the real actor context. If you're sitting behind a private network boundary between Next.js and Go, sign internal forwarding headers with an HMAC and rotate that key like any other secret.
Server-side fetch in a Next.js route handler or server component should be explicit:
1const res = await fetch(`${process.env.API_ORIGIN}/v1/orders`, {2 method: 'POST',3 headers: {4 cookie: `session=${cookieStore.get('session')?.value ?? ''}`,5 'x-request-id': headers().get('x-request-id') ?? crypto.randomUUID(),6 'content-type': 'application/json'7 },8 body: JSON.stringify(payload),9 cache: 'no-store'10})
No magic auth SDK if you can avoid it. Half of them become archaeology sites after the second custom requirement. Signed cookies, explicit forwarding, backend revalidation, done.
streaming and flags
Streaming SSR is where this split starts paying rent. Next.js 15 can flush meaningful UI in tens of milliseconds if you stop waiting for every backend dependency before rendering. The page shell, nav, primary counts, basic account state, those can stream immediately. Slow widgets can suspend, resume later, or hydrate client-side if they aren't critical.
The trap is turning Next.js into a second orchestration layer with domain smarts. Keep page-level composition in Next.js, keep data semantics in Go. A dashboard route might request /v1/me/home, /v1/org/billing-summary, and /v1/announcements. The Go service decides what each payload means. Next.js decides how to render and stream those payloads. If one panel is slow, you should see a fallback UI, not a 1.8 second TTFB because the server component blocked on everything.
Feature flags need the same discipline. Edge can own coarse delivery flags, things like locale variant, marketing experiment bucket, maybe a lightweight layout treatment derived from a signed cookie or deterministic user hash. Product flags that affect permissions, pricing, workflow steps, API shape, or job behavior belong in Go, full stop. If a flag changes business outcomes, the service evaluates it.
One pattern that works well is dual-tier flags with separate names and ownership. edge.nav_v2 lives in Next.js and can be cached aggressively. domain.invoice_auto_submit lives in Go and is evaluated in handlers, jobs, and webhooks. Never mirror the same flag in both places. That sounds tidy in a diagram and becomes chaos during rollout because one cache invalidates later and now support is looking at screenshots that don't match database writes.
For latency, batch where it matters. Give Next.js one backend endpoint tailored for first paint if the page needs five tiny reads and one expensive aggregate. A small BFF surface in Go is fine if it stays presentation-adjacent and doesn't absorb React concerns. The goal is fewer hops, not another monolith.
observability across the seam
If you don't carry trace context across the Next.js to Go boundary, you will blame the wrong tier for every slowdown. The browser waterfall says one thing, Vercel or your edge logs say another, your Go traces show a neat 90 ms handler, and somewhere in the middle there's a missing 600 ms caused by DNS, TLS reuse, queue backpressure, or an accidental uncached fetch in a server component.
Use OpenTelemetry end to end. Next.js can emit spans for middleware, route handlers, server actions, and server-side fetches. Go has mature OTel SDK support and straightforward exporters. Pick one trace header format, W3C trace context is the obvious choice, propagate traceparent and tracestate, add x-request-id for grep-friendly logs, and standardize service names. If your logs say request_id=abc123 trace_id=9f... user_id=42 org_id=7, debugging turns into engineering instead of folklore.
A useful convention is, edge generates the request ID if missing, backend preserves it, workers inherit it through job metadata. For async work, attach the originating trace ID as a link rather than pretending it's the same span tree. That gives you a navigable graph without lying to yourself.
Metrics should reflect the boundary directly. Track edge redirect rate, middleware auth failure count, SSR shell TTFB, backend first-byte latency, fan-out count per route, cache hit ratio on personalized versus public content, and the number of page renders blocked by a nonessential backend call. One ugly but honest dashboard is better than thirty glossy ones no engineer trusts.
The same goes for errors. If Next.js catches TypeError: fetch failed with cause: connect ETIMEDOUT 10.0.12.14:8080, surface that exact message in logs with the route, upstream, and request ID. If Go returns pq: deadlock detected or context deadline exceeded, don't wrap it into some decorative InternalServerErrorException class with the useful bits stripped out. People fix systems with specifics.
a partitioning recipe
If you're rebuilding a monolith into a two-tier stack, don't start by peeling random endpoints away. Start by classifying code into delivery, domain, and async side effects, then move them in that order.
Delivery goes to Next.js first. Middleware auth presence checks, redirects, rewrites, response headers, cache keys, SSR composition, route-level fetch orchestration, static asset behavior, user-facing error boundaries. Domain stays in the monolith until the Go service can fully own it, tests included. Side effects such as emails, webhooks, exports, OCR, report generation, document pipelines, and sync jobs move behind queue-backed workers in Go only after the domain action that emits them is stable.
A practical migration sequence for a typical SaaS app looks like this. Keep the database where it is for a while. Stand up a Go API beside the monolith with a thin auth package and one read-only endpoint used by a new Next.js route. Add tracing before traffic. Move one write path only when you can express the invariant once in Go and delete the old branch. Put background jobs behind an outbox table if they matter. Refuse shared business logic packages across TypeScript and Go, they create fake consistency and real pain.
Your checklist for each piece of logic is short. Does it touch secrets beyond session verification. Does it require a transaction. Can it run longer than a few hundred milliseconds. Must it be identical across web, mobile, jobs, and webhooks. Will a stale cache or stale claim cause a bad decision. Does it need direct access to PostgreSQL, Redis streams, S3 multipart uploads, or a private network. If yes to any of those, it belongs in Go.
Edge runtimes are great at making a system feel fast. They are terrible places to hide the meaning of a system. Respect that boundary and the stack stays understandable, which matters a lot more than squeezing one more diagram-friendly box into your architecture.
