Hey everyone,
After seeing my 5hr screen time on YouTube and Reddit, I realized that apparently you don’t get paid for that. Tragic.
So, riding this sudden burst of motivation, I’m back to remake a project my friends and I made at a hackathon, except this time with a more… complex stack.
The project is called ZeroApi. It’s inspired by tools like MockAPI, and the idea is pretty simple: the user sends us some JSON data, and we generate a mock API from it.
That’s it. That’s the grand plan.
Let’s start.
For this week, the goal is React + TypeScript for the frontend and Go for the backend. I’m intentionally leaving PostgreSQL for later because we don’t actually need a database for the first version.
After that, we’ll containerize everything with Docker. The immediate goal is simply to have a frontend talking to a Go API that actually runs.
The structure I personally use is: root → api folder for go , and web folder for react.
Let’s start with Go
Now, Go is a pretty great language . But compared to frameworks like Django and Springboot, it lacks a framework that matured with prebuilt features.
Under this knowledge, i chose net/http library which means i have to write a lot more things than normal, because apparently I hate myself.
So, for making a server in go, right now we need :
import (
"encoding/json"
"log"
"net/http"
)
encoding/json will let us encode Go values as JSON.
log gives us the standard library's logging functionality.
And net/http is the important one here: it provides the HTTP client/server functionality we need.
Following this, inside main, we create a ServeMux:
mux := http.NewServeMux()
A ServeMux is an HTTP request multiplexer. In simpler terms, it’s responsible for looking at an incoming request and deciding which handler should deal with it based on the request's URL pattern.
Think of it roughly as:
Incoming request
↓
ServeMux
↓
Which pattern matches?
↓
Call that handler
Now we can register our first route:
mux.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Hello from Go!",
})
})
Yes, this is how it looks in go. don’t worry, I too hate how ugly it look. But.
Let's break it down. One by one.
mux.HandleFunc(...) → It registers a handler function for a URL pattern.
The "/{$}" pattern means we want the root path / specifically. if we don’t do that {$} the issue was that all the requests which starts with / ended up giving a 200 status or 300 → 200 status, which is ugly.
The second argument is an anonymous function. This function will be called whenever a matching request comes in.
It receives two arguments:
w http.ResponseWriter
r *http.Request
r represents the incoming HTTP request.
It contains things such as the request method, URL, headers, query parameters, and so on.
w is what we use to construct the HTTP response that we're sending back to the client.
So when we do:
w.Header().Set("Content-Type", "application/json")
We're setting a response header telling the client that the response body contains JSON.
Then:
json.NewEncoder(w).Encode(...)
takes our Go value:
map[string]string{
"message": "Hello from Go!",
}
and encodes it as JSON directly into the response writer.
So the client eventually receives something like:
{
"message": "Hello from Go!"
}
The important thing to understand here is that w is the thing we're writing the HTTP response to.
So this is how we make a server. YAY.
Unfortunately, Not yay.
We've defined what should happen when someone requests /, but we haven't actually started a server yet.
A route by itself doesn't make our application listen for network connections.
That's where this comes in:
http.ListenAndServe(":8080", mux)
This starts an HTTP server listening on port 8080.
The ":8080" part tells it which address/port to listen on.
The mux tells the server which handler should process incoming requests.
So now the flow is:
Browser
↓
GET /
↓
localhost:8080
↓
HTTP server
↓
ServeMux
↓
"/{$}" matches
↓
Our handler runs
↓
JSON response
↓
Browser
And that's enough to get a very basic Go API running.
You can start it with something like:
go run .
assuming you're running it from your Go module directory. or if you have divide work into folders you can do
go run folderName/fileName.go
But, there is one issue still. if you have noticed, you don’t actually see the message “your server is running in localhost:8080” in your terminal.
Yes, we have to print it ourselves. But, don’t worry, instead of using print, we’ll use log function for that because it will print that AND give use more functionalities.
log is useful because ListenAndServe returns an error if the server stops unexpectedly, so a common pattern is:
log.Fatal(http.ListenAndServe(":8080", mux))
Now the logger will print the error and terminate the program if ListenAndServe returns one.
Yes, we’ll implement logs as well. But in next post, which would work as a helper post which would work on making this code more complete. Till then, learn what we did and search some basic things such as : what is NewServerMux()? What are the http request methods? and some basics of golang.
Now, we do react
ok, so we have a server that’s running, good. Now the bad part(Not a bad part, i personally don’t know much of frontend, so if i make any mistake , please correct me)
This is one of my weaker side, so i won’t be able to explain as thoroughly , but here’s what I did so far.
I installed react with : npm create vite@latest . Then i was given some choices i chose this:
Project name: › web
Select a framework: › React
Select a variant: › TypeScript
But an alternative is : npm create vite@latest my-app -- --template react-ts
After that, i deleted the following files, because those are autocreated by react and you don’t need them, and i like my page clean
public/vite.svg
src/assets/react.svg
src/App.css
src/index.css
and then you can change the whole app.tsx to this:
import { useEffect, useState } from 'react'
function App() {
const [message, setMessage] = useState('Loading...')
useEffect(() => {
fetch('/api/')
.then(response => response.json())
.then(data => setMessage(data.message))
}, [])
return (
<main>
<h1>{message}</h1>
</main>
)
}
export default App
Docker
Before compose, Docker needs something to build. Compose just orchestrates. So we need 4 files:
root
├── docker-compose.yml
├── api
│ └── Dockerfile
└── web
├── Dockerfile
└── nginx.conf
a Dockerfile is a recipe for building an image (a packaged snapshot of your app). A running instance of that image is a container. Compose lets us start multiple containers with one command.
Go Dockerfile (api/Dockerfile)
# Build stage
FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY go.mod ./
# COPY go.sum ./ # Uncomment if you have a go.sum
RUN go mod download
COPY . .
RUN go build -o server .
# Run stage
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
CMD ["./server"]
This is a multi-stage build: one image builds the app, then we throw it away and keep only the result.
Stage 1: Start from an image with Go installed (alpine = tiny Linux). We copy go.mod and download dependencies before copying the rest of the code. Docker caches each step, so if dependencies haven't changed, rebuilds skip the download. Then we compile everything into one binary called server.
Stage 2: A fresh, tiny Alpine image with no Go and no source code. We copy only the binary from stage 1 and run it. EXPOSE 8080 is just documentation, it doesn't open anything by itself.
React Dockerfile (web/Dockerfile)
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Serve stage
FROM nginx:alpine
# Vite outputs to /dist, Create React App outputs to /build
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
Same idea. Stage 1 installs dependencies and runs npm run build, which turns our TypeScript/React into plain static files in dist. Once built, we don't need Node anymore, so stage 2 uses nginx (a web server) to serve those files, with our own config.
nginx.conf (web/nginx.conf)
server {
listen 80;
# Serve React app
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
# Proxy API requests to the Go backend
location /api/ {
proxy_pass http://backend:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
This is the glue. Think of it like our ServeMux, but for the whole app:
Browser
↓
nginx (port 80)
↓
Path starts with /api/ ?
↓ ↓
No Yes
↓ ↓
React files Go backend
location / serves the React app. The try_files line falls back to index.html if a file doesn't exist, which matters once we add routing.
location /api/ forwards to Go. Notice the hostname backend: Docker Compose puts all containers on one network and they can reach each other by service name. The trailing / in proxy_pass strips /api/, so /api/ from the browser becomes / in Go. That's why our fetch('/api/') hits the "/{$}" route.
docker-compose.yml (in the root)
services:
backend:
build: ./api
# No ports needed, nginx reaches it internally
frontend:
build: ./web
ports:
- "80:80" # your machine : container
depends_on:
- backend # starts backend first
One service per folder. The backend has no ports on purpose: only nginx is public, and the API is only reachable through /api/. depends_on starts the backend first, but it only waits for the container to start, not to be ready. Fine for now, but we'll need healthchecks once PostgreSQL shows up.
Run it
docker compose up --build
Open http://localhost and you should see "Hello from Go!". That means React loaded, called /api/, nginx forwarded it, and Go answered.
Yes, that was a lot of config for one line of text. But the setup is done, and from here we build features.
So…thats all for now, I hope you all come in this journey with me. As I am learning things myself as I move forward, So I am open to criticism and suggestions as we move.
If I got anything wrong (especially frontend or nginx), please correct me in the comments.
Top comments (0)