This episode builds your first running chi project: creating a router with chi.NewRouter, registering the first handler, and running the server with http.ListenAndServe. You will also learn the Handler and ServeHTTP structure, and return JSON responses manually.

Enough theory. Episode 3 takes you through building your first actually-running chi project: hello world. This is the most important moment in a series — when you see your first route come alive and respond to a request through curl, all the concepts from episodes 1 and 2 start to feel real.
This episode's goals: build a small server with two endpoints, understand what http.Handler and ServeHTTP are, and return JSON manually without any extra library. All of these patterns will become the foundation for every following episode.
Make sure the environment from episode 0 is ready. Create the module and install chi v5:
mkdir hello-chi
cd hello-chi
go mod init github.com/username/hello-chi
go get github.com/go-chi/chi/v5@latestgo get github.com/go-chi/chi/v5@latest adds the dependency to go.mod. After that, create the file main.go — our entire hello world app lives in this single file.
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
)
func main() {
r := chi.NewRouter()
r.Get("/", func(w http.ResponseWriter, req *http.Request) {
w.Write([]byte("Halo Dunia"))
})
http.ListenAndServe(":8080", r)
}chi.NewRouter() creates an empty router, then r.Get("/", handler) registers a handler for the GET method on the root path. The handler is written as a closure receiving an http.ResponseWriter and a *http.Request — exactly the same shape as http.HandlerFunc.
Run the server, then test it with curl from another terminal:
go run main.gocurl -i http://localhost:8080/The expected response: status 200 OK, headers from http.Server, and body Halo Dunia. curl -i http://localhost:8080/ shows both headers and body so you can see the full response.
Tip
Since r is an http.Handler, you can also run it with http.ListenAndServe inside a goroutine and handle shutdown — we'll cover that in episode 10.
Actually, a handler only needs to implement one method:
type Handler interface {
ServeHTTP(w http.ResponseWriter, req *http.Request)
}When we write func(w http.ResponseWriter, req *http.Request), Go automatically turns it into an http.HandlerFunc that implements that interface. This is where chi's beauty lies: there is no special handler type. A chi handler is a net/http handler.
For real projects, handlers shouldn't be anonymous closures inside main:
func helloHandler(w http.ResponseWriter, req *http.Request) {
w.Write([]byte("Halo Dunia"))
}
func main() {
r := chi.NewRouter()
r.Get("/", helloHandler)
http.ListenAndServe(":8080", r)
}The r.Get("/", helloHandler) pattern uses a function of type http.HandlerFunc, which chi uses automatically without manual conversion.
REST APIs almost always return JSON. Manual encoding is simple enough:
func healthHandler(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
})
}Order matters: set headers first, write the status, then encode the body. json.NewEncoder(w).Encode(...) writes JSON directly to the ResponseWriter with proper escaping.
Add a health endpoint to the router:
func main() {
r := chi.NewRouter()
r.Get("/", helloHandler)
r.Get("/health", healthHandler)
http.ListenAndServe(":8080", r)
}Now test both endpoints:
curl -i http://localhost:8080/healthcurl -i http://localhost:8080/health should return status 200 OK with the header Content-Type: application/json and body {"status":"ok"}.
Every handler in chi can be moved to http.ServeMux or http.ListenAndServe without changes. And vice versa: any net/http handler can be registered with chi. This principle is what makes chi compatible with the entire ecosystem.
To prove it, swap the router for http.ServeMux:
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", helloHandler)
http.ListenAndServe(":8080", mux)
}The helloHandler runs smoothly without touching a single line. http.NewServeMux() is what chi wraps with the added params, subrouters, and middleware — topics we start in episode 4.
Key takeaways:
go mod init then go get github.com/go-chi/chi/v5@latest.chi.NewRouter() then r.Get("/", handler) is the basic recipe.http.Handler; there's no special type.http.ListenAndServe(":8080", r).net/http — handlers can move back and forth without modification.In the next episode 4 we go into the heart of the router: routing, params, and patterns — method routing, the path param {id}, wildcard {path:*}, regex {id:[0-9]+}, and NotFound and MethodNotAllowed. This will change the way you look at URLs.