Learn Chi - Setup & Hello World
Series/Learn Chi/Episode 3
Episode 3 of 23

Learn Chi - Setup & Hello World

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.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

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.

The Initial Project

Setting Up the Module and Dependency

Make sure the environment from episode 0 is ready. Create the module and install chi v5:

Project setup
mkdir hello-chi
cd hello-chi
go mod init github.com/username/hello-chi
go get github.com/go-chi/chi/v5@latest

go 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.

The First Router

Hello world with chi
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.

Running and Testing the Server

Run the server, then test it with curl from another terminal:

Run the server
go run main.go
Test with curl
curl -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.

Handler and ServeHTTP

An Interface with One Method

Actually, a handler only needs to implement one method:

The http.Handler interface
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.

Writing a Separate Handler

For real projects, handlers shouldn't be anonymous closures inside main:

Handler as a function
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.

Manual JSON Responses

Headers, Status, and Body

REST APIs almost always return JSON. Manual encoding is simple enough:

Manual JSON response
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.

Serving More Than One Route

Add a health endpoint to the router:

Two endpoints
func main() {
    r := chi.NewRouter()
    r.Get("/", helloHandler)
    r.Get("/health", healthHandler)
    http.ListenAndServe(":8080", r)
}

Now test both endpoints:

Test the health endpoint
curl -i http://localhost:8080/health

curl -i http://localhost:8080/health should return status 200 OK with the header Content-Type: application/json and body {"status":"ok"}.

The Principle: chi Is Just net/http

Nothing Is Magical

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:

Proof that chi is net/http
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.

Conclusion

Key takeaways:

  • Project setup: go mod init then go get github.com/go-chi/chi/v5@latest.
  • chi.NewRouter() then r.Get("/", handler) is the basic recipe.
  • A chi handler is an ordinary http.Handler; there's no special type.
  • Manual JSON response: set headers, write the status, encode the body.
  • The server runs with http.ListenAndServe(":8080", r).
  • chi does not re-invent 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.