Requests & Responses with the Standard Library
Here's the deal with chi, stated plainly so it never surprises you: chi is a
router and nothing else. It matches a method and path to a handler, and then
it hands you the same w http.ResponseWriter and r *http.Request you'd get
from the bare standard library. There's no c.JSON(...), no c.Bind(...), no
magic context object with convenience methods bolted on.
So all the request/response work — reading a JSON body, writing a JSON body,
choosing a status code — is done with the standard library, mostly the
encoding/json package plus net/http. That sounds like more work than Gin or
Echo, and in raw line count it is a little. But it's a small amount of code, you
write it once, and the payoff is that you're learning Go's HTTP model, not
chi's. Everything here works in any net/http program, framework or not.
📝 The mental model for this phase: the request and response are streams of bytes, and
encoding/jsonis your translator at both ends. Reading = decode the request body's bytes into a struct. Writing = encode a struct into the response body's bytes. chi is not involved in either direction.
We'll keep growing the articles API. The data type is the same one from earlier phases:
What just happened: Article is a plain struct with JSON struct tags. Those
backtick tags tell encoding/json what each field is called in JSON
("title", not "Title"). The tags work in both directions — decoding and
encoding — which is why we define them once and never think about them again.
Reading a JSON body
A client sends POST /articles with a JSON body. You want to turn those bytes
into a Go value. The tool is json.NewDecoder, which reads directly from
r.Body (an io.Reader — a stream), so you never have to load the whole body
into a string yourself.
A common pattern is to decode into a small input struct rather than straight
into Article. The input is "what the client is allowed to send" — usually not
the same as your full model (the client doesn't get to pick the id, for
instance).
if json; err != nil
// in.Title and in.Body now hold the decoded values.
// ... create the article, assign an ID, store it ...
}
What just happened: json.NewDecoder(r.Body) wraps the request body stream.
.Decode(&in) reads the JSON and fills in the struct's fields by matching JSON
keys to struct tags — note the &, because Decode needs a pointer so it can
write into your variable. The critical part is the error check: malformed JSON
is the client's fault, so it's a 400, not a 500. http.Error writes a plain
text message and sets the status in one call. The bare return after it is
essential — without it, the handler would keep running on garbage data.
⚠️ A decode error covers a body that isn't valid JSON at all. It does not
catch JSON that's valid but wrong — {"title": 5} (number where a string is
expected) errors, but {"titlee": "oops"} (typo'd field) silently decodes to an
empty Title. By default, unknown fields are just ignored. If you'd rather
reject them — useful for catching client typos early — turn it on explicitly:
json
dec
if dec; err != nil
What just happened: DisallowUnknownFields() flips the decoder into strict
mode, so a body with a key your struct doesn't have now produces an error
instead of being quietly dropped. It's a small line that turns a whole class of
silent client bugs into loud 400s. Use it when you control the clients and want
tight contracts; skip it for public APIs where forward-compatibility (clients
sending fields you don't know yet) is a feature.
Writing JSON — and the one ordering rule that bites everyone
Now the other direction: you have an Article (or any value) and want to send it
back as JSON. There's no built-in WriteJSON, so everyone writes a tiny helper.
Here's the canonical one:
What just happened: three steps, in this exact order. First, set the
Content-Type header so the client knows it's getting JSON. Second, write the
status line with w.WriteHeader(status). Third, encode the value straight onto
w (the ResponseWriter is an io.Writer, so the encoder streams JSON bytes
into the response body). any is Go's alias for interface{}, so this helper
takes any value you can marshal.
⚠️ This is the number-one stdlib HTTP gotcha, so read it twice. The response must be built in this order: headers first, then status, then body. The reason is how HTTP works on the wire — the headers and status line are sent before the body, and once any of those go out, they're locked. Concretely:
w.Header().Set(...)must come beforew.WriteHeader(...). AfterWriteHeader, the header block is already on its way; setting a header after that is too late and is silently ignored.w.WriteHeader(...)must come before you write the body. The first call tow.Write(...)(whichEncodedoes internally) flushes the status line — and if you never calledWriteHeader, that first write implicitly sends200 OK. So if you encode the body and then try to set a 201, your 201 is ignored and the client already got a 200.
Get the order wrong and there's no crash, no error — just a response with the wrong status or a missing header, discovered later in a confused debugging session. Bake the order into the helper (as above) and you never think about it again.
Using the helper makes handlers read cleanly:
if json; err != nil
Article
}
What just happened: the handler decodes the input, builds a real Article
(assigning the server-controlled ID itself), and replies with 201 Created
plus the new article as JSON. Read it top to bottom and the request/response
shape is obvious — exactly because the ordering complexity is hidden in
writeJSON.
Status codes and the empty-body case
Status codes are just integer constants in net/http, and using the named ones
keeps your handlers readable. The ones you'll reach for constantly:
http.StatusOK(200) — a normal successful GET.http.StatusCreated(201) — you just created a resource (POST).http.StatusNoContent(204) — success, but there's nothing to send back.http.StatusBadRequest(400) — the client's request was malformed.http.StatusNotFound(404) — the thing they asked for doesn't exist.
The 204 case is special because the rule is: 204 means no body, so don't write
one. A DELETE that succeeds is the classic example:
What just happened: we set the status to 204 and then stop — no
writeJSON, no Encode, nothing. There's no body to send, so we don't reach for
the helper at all. (If you accidentally wrote a body after a 204, you'd be
contradicting the status code, and some clients will complain.)
That snippet also quietly reuses two request-reading tools from earlier phases, worth a one-line refresher since you'll use them in the same handlers as your JSON work:
chi // path param from a route like /articles/{id}
r.URL // query string param from ?sort=title
What just happened: chi.URLParam(r, "id") pulls a value out of the path
for routes declared with {id} (this is the one helper chi itself provides, and
it reads from data chi stashed on the request). r.URL.Query().Get("sort") is
pure stdlib and reads a query-string value, returning "" if it's absent.
Both give you strings — converting id to an int with strconv.Atoi (and
handling the error as a 400) is on you.
Validation: there's no net here
This is the honest tradeoff of the stdlib approach. After you decode in,
nothing has checked that the data makes sense. An empty title, a 50,000-word
body, a missing field — encoding/json doesn't care. Validation is your job, by
hand:
if in.Title == ""
What just happened: a plain if. That's the whole validation story in raw
stdlib — you check the fields you care about and return a 400 when something's
off. For two or three fields this is fine and arguably clearer than anything
fancier. For a large API with many rules, hand-written checks pile up fast, and
that's where a library earns its keep.
💡 Two ways to get more help without abandoning the stdlib model. (1) The
github.com/go-playground/validatorpackage lets you declare rules as struct tags —validate:"required,min=1"— and validate with one call; you opt into it, it doesn't take over your handlers. (2) chi ships a companion package,github.com/go-chi/render, with JSON helpers (render.JSON,render.Bind,render.Status) that wrap the decode/encode/order dance for you. Both are optional — the plain stdlib shown above is genuinely enough for most APIs, and it's the version that teaches you what's actually happening.
And that's the philosophical fork in the road. Gin and Echo come with
batteries — c.ShouldBindJSON decodes and validates in one call, c.JSON
handles the header/status/body order for you. chi deliberately ships none of
that, betting that a writeJSON helper and a few if statements are a fair
price for staying 100% standard-library-native. Neither choice is wrong; now you
know exactly what each one is trading away.
Recap
- chi gives you a router and nothing else for I/O — you read and write with
encoding/jsonandnet/http, the same as bare stdlib. - Read a body with
json.NewDecoder(r.Body).Decode(&in); a decode error is a client mistake, so return 400 andreturnimmediately. UseDisallowUnknownFields()for strict contracts. - Write JSON with a small helper, and respect the order: header → status →
body. The first body write locks the status (and defaults to 200 if you
never set one), so headers and
WriteHeadermust come first. - Use named
net/httpstatus constants. 204 means no body — set the status and write nothing. - There's no built-in validation — check fields by hand, or opt into
go-playground/validator/go-chi/render. That's the deliberate tradeoff versus Gin/Echo's batteries.
Quick check
[
{
"q": "In the writeJSON helper, what's the correct order of operations?",
"choices": ["WriteHeader, then Set the Content-Type header, then Encode the body", "Set the Content-Type header, then WriteHeader, then Encode the body", "Encode the body, then WriteHeader, then Set the header", "Set the header and WriteHeader in any order, then Encode"],
"answer": 1,
"explain": "Headers must be set before WriteHeader, and WriteHeader before the body — once the body is written the status and headers are locked."
},
{
"q": "A client sends a body that is not valid JSON. What should the handler do?",
"choices": ["Return 500 Internal Server Error", "Ignore it and continue with zero values", "Return 400 Bad Request and stop processing", "Return 204 No Content"],
"answer": 2,
"explain": "A malformed body is the client's fault, so it's a 400, and you must return immediately so the handler doesn't run on garbage data."
},
{
"q": "You want a successful DELETE to return 204 No Content. What do you write?",
"choices": ["writeJSON(w, http.StatusNoContent, article)", "w.WriteHeader(http.StatusNoContent) and write no body", "json.NewEncoder(w).Encode(nil)", "http.Error(w, \"\", 204)"],
"answer": 1,
"explain": "204 means there is no body — set the status and write nothing at all."
}
]
← Phase 3: Middleware the Standard Way · Guide overview · Phase 5: Building a REST API →
Check your understanding
1. In the writeJSON helper, what's the correct order of operations?
2. A client sends a body that is not valid JSON. What should the handler do?
3. You want a successful DELETE to return 204 No Content. What do you write?