The first time you connect a frontend to your own backend, something always breaks that has nothing to do with your actual code. A red CORS error. A port that works locally and fails on deploy. A request that succeeds on the server and dies in the browser.
This article walks through the whole journey: designing a Go REST API, connecting it to a Next.js frontend, and deploying both. I’ll use a small cake ordering service as the running example, where customers browse cakes and place orders. The focus is on the decisions and the reasoning behind them, especially around the boundary between the two halves, because that’s where most full-stack bugs live.
Two projects, one conversation
The setup is two separate applications that talk over HTTP. The Go service owns the data and the business rules, and returns JSON. The Next.js application owns the user interface and calls the API.
You could serve everything from one process, but keeping them separate mirrors how many real teams work. It also forces you to deal honestly with cross-origin requests and configuration, which is exactly the part tutorials tend to skip. If you learn to wire up two independent services once, you can reuse that knowledge on almost any stack.
Start with the contract
Before writing any code, write down what the API does. A plain text list is enough: which endpoints exist, what each one accepts, and what each one returns. For our cake service, the contract is small. Customers can list all cakes, fetch a single cake, place an order, and look up an order by its reference.
Two rules go in the contract from day one. First, every response is JSON. Second, every error has the same shape: a single field containing a human-readable message. That consistency seems minor, but it means the frontend can have one error handler instead of five, and anyone joining the project can predict how failures look.
Writing the contract first has a side benefit: it lets the frontend and backend work in parallel. The frontend developer can build against the agreed responses before the backend exists. Disagreements surface in a text file, where they cost minutes, instead of in a pull request, where they cost days.
Designing the Go API
Think in layers
The most useful design principle for a Go API is separation of concerns. Three things should be kept apart: handling HTTP, representing and validating data, and storing data.
The HTTP layer translates between the outside world and your application. It reads a request, calls into your logic, and writes a response. It should contain very little else. The data layer defines what a cake and an order look like and what makes an order valid. The storage layer saves and retrieves things, and it should be hidden behind an interface so the rest of the application doesn’t know or care whether data lives in memory, in Postgres, or somewhere else.
This separation is what makes the code easy to change. When you swap in a real database later, you write one new implementation of the storage interface and change a single line where the application is wired together. No handler is touched. It’s also what makes testing pleasant, because you can test handlers against a lightweight fake store without spinning up a database.
Start with in-memory storage
I recommend starting with a simple in-memory implementation, even if you know you’ll eventually need a database. It gets you a working API on day one, which unblocks the frontend. And because it sits behind an interface, you lose nothing by starting simple.
Two practical details matter here. First, HTTP servers handle requests concurrently, so any shared map needs protection with a lock, or your server will eventually crash under load. Second, map iteration order in Go is deliberately random, so if you return a list of cakes, sort it. Otherwise the order of cakes on your page will shuffle between requests, and your frontend will look haunted.
Model your data carefully
Keep models plain and put validation next to the data it validates. An order, for example, needs a customer name, a phone number, and at least one item, and every item needs a cake reference and a quantity of at least one. Putting those rules on the model rather than inside a handler gives you one obvious place for business rules. When someone asks where to add a new rule, the answer is never “somewhere in the handlers.”
Store money as whole numbers in the smallest sensible unit rather than as decimals. Floating-point arithmetic is famously imprecise, and customers have a talent for noticing when their total is off by a cent.
Write handlers that are boring
A good handler does four things: it reads the request, validates it, calls the storage layer, and writes a response. If a handler is doing much more, logic has leaked into the wrong place.
Since Go 1.22, the standard library router supports HTTP methods and path parameters, so for many APIs you no longer need a third-party framework. That means fewer dependencies, fewer upgrades to worry about, and a codebase that any Go developer can read immediately.
A few habits make handlers much more robust:
-Limit the request body size. Without a cap, a client can send you an enormous payload and eat your memory.
-Reject unknown fields in incoming JSON. It catches typos in the frontend early, instead of silently ignoring them.
-Use accurate status codes. A 400 means the request couldn’t be understood at all. A 422 means it was understood but the data is invalid. A 404 means the thing doesn’t exist. A 201 means something was created. A 500 means the failure is yours.
-Never leak internal errors to clients. Log the details on the server, and send back a generic message. Database error text in an API response is a gift to attackers and a confusion to users.
Status codes are part of your contract. A frontend that can trust them can make good decisions, like showing a form error for a 422 versus a “try again later” banner for a 500.
Middleware for cross-cutting concerns
Some behaviour applies to every request: logging, recovering from crashes, and setting CORS headers. Middleware handles this by wrapping your handlers.
A logging middleware records the method, path, status, and duration of every request, which is invaluable when something goes wrong in production. A recovery middleware catches panics so one bad request doesn’t take the whole service down, and returns a clean JSON error instead. And a CORS middleware, which deserves its own section.
CORS, explained properly
CORS is the part that makes people want to throw their laptops, mostly because it’s rarely explained from first principles. So let’s do that.
Browsers enforce something called the same-origin policy. An origin is the combination of scheme, host, and port. If your Next.js app runs on one port locally and your API on another, those are different origins, even though both are on your machine. By default, JavaScript running in the browser is not allowed to read responses from a different origin than the page it’s running on.
CORS is the mechanism by which a server says, “I’m okay with that origin reading my responses.” The server communicates this through response headers: which origin is allowed, which methods, and which request headers.
Preflight requests
Here’s the part that causes the most confusion. For simple requests, the browser sends the request and checks the response headers afterward. But for anything more involved, such as a POST with a JSON content type, the browser first sends a preflight: an OPTIONS request asking the server whether the real request would be acceptable.
If your server doesn’t answer that OPTIONS request correctly, the browser never sends the actual request. This is why you can see a CORS error in the browser for a request your server logs show it never received. It’s also why the bug often seems to appear only on POST requests while your GET requests work fine.
Rules that save hours
- CORS is enforced by browsers only. Command-line tools and server-side code ignore it entirely. This explains the classic complaint, “it works in Postman but not in my app.” Postman isn’t a browser, so it never checked.
- The origin must match exactly. Scheme, host, and port all count, so the plain and “www” versions of a domain are different origins. A trailing slash in the configured origin means it will never match.
- The preflight must return a success status. If your router answers OPTIONS with a 404 or 405 because the CORS handling happens too late, the browser will refuse to proceed. -Avoid the wildcard in production unless your API is genuinely public. Read the allowed origin from configuration so each environment gets the right one.
If you take away one thing from this article, let it be this: when you see a CORS error, don’t start guessing. Open the browser’s network tab, find the OPTIONS request, and look at its status and its response headers. The answer is almost always right there.
Server settings worth setting
One more thing before leaving the API. The default Go HTTP server has no timeouts, which leaves it vulnerable to clients that connect and then send data very slowly, tying up resources. Set explicit read, write, and header timeouts. It takes a few lines and closes a real hole.
And read the port from the environment. Hosting platforms assign your service a port and expect you to listen on it. Hardcoding one is the number one reason a deployment succeeds but never becomes healthy.
Before touching the frontend, test your API on its own using a command-line tool. If it doesn’t work there, no amount of frontend work will fix it. Always verify each half in isolation first.
Building the Next.js side
Configuration and the build-time trap
The frontend needs to know where the API lives, and the right way to supply that is through an environment variable. Here’s the subtlety that catches nearly everyone once: Next.js only exposes environment variables to browser code if their names start with a special public prefix, and it bakes those values into the bundle at build time.
Two consequences follow. First, never put a secret in a public-prefixed variable, because anyone can read it in their browser. Second, changing the value after the app is built does nothing until you rebuild. People change the API URL in their hosting dashboard, restart the service, and wonder why nothing changed. The old value is baked into the bundle. You need a fresh build.
One place for API calls
Centralise all communication with the backend in a single module: the base URL, a shared request helper, and typed functions for each endpoint. Because our Go API always returns errors in the same shape, the helper can extract the server’s message and throw it as a normal error, so every part of the UI handles failures the same way.
Typing the responses, if you’re using TypeScript, gives you a second contract check. If the backend changes a field name, the compiler tells you everywhere the frontend relied on it. It’s one of the quiet advantages of keeping both sides well described.
Server versus browser fetching
This distinction explains a huge share of “why does this half work and that half doesn’t” confusion.
In the Next.js App Router, a server component can fetch data directly. That request is made by the Next.js server, not by the user’s browser, so CORS doesn’t apply to it at all. Fetching the cake list on the server is simple and fast, and the user receives finished HTML.
But the order form is interactive. It has state, input handlers, and a submit button, so it must be a client component, and its requests are made from the user’s browser. Those requests are cross-origin, they need the CORS headers, and they trigger the preflight we discussed.
So a very common experience goes like this: the cake list loads perfectly, but the order form fails with a CORS error. It looks like a contradiction, but it isn’t. One request came from a server that ignores CORS, and the other from a browser that enforces it. Once you understand that, the symptom becomes a clue instead of a mystery.
Handle every state of a request
A form that submits to a network can be in several states: waiting for input, sending, succeeded, or failed. Model these explicitly rather than juggling separate flags. When the states are mutually exclusive in your code, you can’t accidentally display a success message and an error at the same time.
Show the user something while a request is in flight, disable the submit button to prevent double orders, and display the server’s error message when validation fails. These small touches are the difference between a demo and something a real customer can use.
Also remember that the server is the source of truth. The frontend can validate for convenience, but the API must validate for correctness, because anyone can bypass your interface and call your endpoints directly.
Deploying both halves
Local development hides problems that deployment reveals. Most of them come down to configuration.
The API
Deploy the Go service as a web service on your host of choice. You’ll need a build step that compiles the binary and a start step that runs it. Configure two things through environment variables: the allowed origin for CORS, and nothing for the port, which the platform provides.
Add a lightweight health check endpoint that simply returns success. Hosting platforms use it to decide whether your service has started properly, and it’s useful for your own monitoring too.
If you’re on a free tier, be aware that idle services are often put to sleep, so the first request after a quiet period can take a minute. This is not a bug in your code. Design for it by showing a clear loading state, and don’t waste an evening debugging a cold start.
The frontend
Deploy the Next.js app as its own service. Set the API URL variable to the deployed API’s address, and remember that it requires a rebuild to take effect.
The chicken-and-egg problem
Each service needs the other’s URL: the API needs the frontend’s address for CORS, and the frontend needs the API’s address to call it. The way out is to do it in a specific order.
- Deploy the API first, with a placeholder for the allowed origin.
- Deploy the frontend, pointing at the API’s real address.
- Copy the frontend’s real address back into the API’s configuration and redeploy the API.
- Open the live site with the browser’s network tab open and place a test order.
When it breaks
Work through these in order whenever something fails after deployment:
- Does a direct request to the API work from the command line? If not, the problem is the API, not CORS.
- In the browser’s network tab, is there a preflight request, and what status did it return?
- Does the allowed-origin response header exactly match the page’s origin, with the right scheme and no trailing slash?
- Is the page served over HTTPS while the API URL uses plain HTTP? Browsers block that as mixed content.
- Did you rebuild the frontend after changing the API URL?
- Nine times out of ten, the culprit is on that list.
Where to go from here
You now have a working full-stack application with a deliberate boundary between its halves. There are several natural next steps.
Replace the in-memory storage with a real database by writing a second implementation behind the same interface. Add automated tests, taking advantage of the fact that handlers can be tested without a database. Add request identifiers to your logs so you can trace one customer’s order through the whole system. And add authentication once you introduce administrative actions, such as marking an order as fulfilled.
The broader lesson isn’t any single technique. Most full-stack bugs live at the seam between the two halves, in headers, URLs, and environment variables, rather than inside either codebase. Design that seam on purpose, write down the agreement, test each side by itself, and the rest of the work becomes much more predictable.
If this helped, or you hit a snag I didn’t cover, leave a comment and I’ll add it to the article.
