Templ With Chi Gin and the Standard Mux in Go Web Apps
Use templ components with the standard mux, chi, gin and echo: templ.Handler, path parameters, 404 pages, status codes and the differences between routers.

One of the reasons to use templ is that it does not come with a router. A component is a function that returns something with a Render method, and that works anywhere you can get hold of an http.ResponseWriter. This article mounts the same two components under the standard ServeMux, chi, gin and echo, and compares what comes out, because the interesting part is not that it works, it is where the routers differ.
Tested with Go 1.25.6, templ v0.3.1001, chi v5.5.0, gin v1.12.0 and echo v4.15.0, macOS on Apple Silicon (arm64)
It builds on the tutorial, complements the router comparison in the standard library or a framework and the framework roundup, and belongs to the web cluster that ends in a production HTTP server. Everything ran on Go 1.25.6 with the versions in the tested line above.
The components and the four routers
package main
templ Hello(name string, via string) {
<!DOCTYPE html>
<html lang="en">
<head><title>Hello { name }</title></head>
<body>
<h1>Hello, { name }</h1>
<p>Rendered through { via }.</p>
</body>
</html>
}
templ NotFound() {
<h1>Not found</h1>
}package main
import (
"net/http"
"github.com/a-h/templ"
"github.com/gin-gonic/gin"
"github.com/go-chi/chi/v5"
"github.com/labstack/echo/v4"
)
// The standard mux: a templ component is a handler once wrapped, and the path value is read in a small closure.
func stdMux() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /hello/{name}", func(w http.ResponseWriter, r *http.Request) {
templ.Handler(Hello(r.PathValue("name"), "net/http")).ServeHTTP(w, r)
})
mux.Handle("/", templ.Handler(NotFound(), templ.WithStatus(http.StatusNotFound)))
return mux
}
// chi: handlers are plain net/http handlers, so the standard-library pattern carries over.
func chiRouter() http.Handler {
r := chi.NewRouter()
r.Get("/hello/{name}", func(w http.ResponseWriter, r *http.Request) {
templ.Handler(Hello(chi.URLParam(r, "name"), "chi")).ServeHTTP(w, r)
})
r.NotFound(templ.Handler(NotFound(), templ.WithStatus(http.StatusNotFound)).ServeHTTP)
return r
}
// gin: call templ.Handler from inside the gin handler, so you keep its buffering and Content-Type.
func ginRouter() http.Handler {
gin.SetMode(gin.ReleaseMode)
g := gin.New()
g.GET("/hello/:name", func(c *gin.Context) {
templ.Handler(Hello(c.Param("name"), "gin")).ServeHTTP(c.Writer, c.Request)
})
g.NoRoute(gin.WrapH(templ.Handler(NotFound(), templ.WithStatus(http.StatusNotFound))))
return g
}
// echo: the same idea with its own context type.
func echoRouter() http.Handler {
e := echo.New()
e.GET("/hello/:name", func(c echo.Context) error {
templ.Handler(Hello(c.Param("name"), "echo")).ServeHTTP(c.Response(), c.Request())
return nil
})
e.RouteNotFound("/*", func(c echo.Context) error {
templ.Handler(NotFound(), templ.WithStatus(http.StatusNotFound)).ServeHTTP(c.Response(), c.Request())
return nil
})
return e
}The mechanics are short. templ.Handler(component) returns an http.Handler, so the standard mux and chi, whose handlers are net/http handlers, use it directly or in a closure that reads the path parameter first. gin and echo have their own handler types, but both expose the underlying http.ResponseWriter and *http.Request (c.Writer and c.Request in gin, c.Response() and c.Request() in echo). Calling templ.Handler(...).ServeHTTP(w, r) from inside the framework handler hands the request to templ with a standard writer, which keeps three properties you would otherwise rebuild by hand: output buffered until the render succeeds, a correct Content-Type, and error handling that yields a 500 instead of a truncated page. The alternative, calling component.Render(ctx, c.Writer) directly, works and writes straight to the connection, which is what you want for streaming and a mistake for ordinary pages, as the first-component article showed.
For the not-found page, templ.WithStatus(http.StatusNotFound) sets the status, and each router has its own hook to route unmatched requests there: the catch-all / pattern for the standard mux, NotFound for chi, NoRoute for gin and RouteNotFound for echo.
What differs between the routers
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"regexp"
)
func main() {
routers := []struct {
name string
h http.Handler
}{{"net/http", stdMux()}, {"chi", chiRouter()}, {"gin", ginRouter()}, {"echo", echoRouter()}}
h1 := regexp.MustCompile(`<h1>[^<]*</h1>`)
for _, path := range []string{"/hello/Mara", "/hello/%3Cb%3EX%3C%2Fb%3E", "/nowhere"} {
fmt.Println(path)
for _, rt := range routers {
rec := httptest.NewRecorder()
rt.h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
fmt.Printf(" %-9s %d %-24s %s\n", rt.name, rec.Code, rec.Header().Get("Content-Type"), h1.FindString(rec.Body.String()))
}
}
rec := httptest.NewRecorder()
ginRouter().ServeHTTP(rec, httptest.NewRequest("POST", "/hello/Mara", nil))
fmt.Println("\nwrong method on gin:", rec.Code, "| on chi:", func() int {
r := httptest.NewRecorder()
chiRouter().ServeHTTP(r, httptest.NewRequest("POST", "/hello/Mara", nil))
return r.Code
}(), "| on net/http:", func() int {
r := httptest.NewRecorder()
stdMux().ServeHTTP(r, httptest.NewRequest("POST", "/hello/Mara", nil))
return r.Code
}())
}go get github.com/a-h/[email protected] >/dev/null 2>&1
go get -tool github.com/a-h/templ/cmd/[email protected] >/dev/null 2>&1
go tool templ generate >/dev/null 2>&1
go run ./hello/Mara
net/http 200 text/html; charset=utf-8 <h1>Hello, Mara</h1>
chi 200 text/html; charset=utf-8 <h1>Hello, Mara</h1>
gin 200 text/html; charset=utf-8 <h1>Hello, Mara</h1>
echo 200 text/html; charset=utf-8 <h1>Hello, Mara</h1>
/hello/%3Cb%3EX%3C%2Fb%3E
net/http 200 text/html; charset=utf-8 <h1>Hello, <b>X</b></h1>
chi 200 text/html; charset=utf-8 <h1>Hello, %3Cb%3EX%3C%2Fb%3E</h1>
gin 404 text/html; charset=utf-8 <h1>Not found</h1>
echo 200 text/html; charset=utf-8 <h1>Hello, %3Cb%3EX%3C%2Fb%3E</h1>
/nowhere
net/http 404 text/html; charset=utf-8 <h1>Not found</h1>
chi 404 text/html; charset=utf-8 <h1>Not found</h1>
gin 404 text/html; charset=utf-8 <h1>Not found</h1>
echo 404 text/html; charset=utf-8 <h1>Not found</h1>
wrong method on gin: 404 | on chi: 405 | on net/http: 404The output is where the differences show up.
For a plain name, all four routers return the same <h1>. That is the point of the exercise and the reason templ code does not change when you swap routers.
The second block uses a name containing percent-encoded characters, including an encoded slash (%2F). The standard mux decoded the path value to <b>X</b> and templ escaped it, so the page shows it as text. chi and echo handed the component the value still percent-encoded, so the page displays %3Cb%3EX%3C%2Fb%3E; no harm done and no injection, but not what a user typing that name would expect. gin did not match the route at all and fell through to the 404 page. If you accept free text in a path segment, test an encoded slash on your router, and prefer a query parameter or a request body for anything that can contain /.
The last line is the status for a wrong method. chi answered 405, gin 404, and the standard mux 404 because this example registered a catch-all / for the not-found page, which also matches a POST to /hello/Mara. That last result is the one to remember: a catch-all pattern on the standard mux changes its 405 behavior, as explained in custom 404 pages. If your API contract says 405, register the method explicitly or check it in the catch-all.
Error pages and middleware in each router
Rendering errors deserve one answer across the whole application, whichever router carries the request. templ.WithErrorHandler takes a function from the request and the error to an http.Handler, so a single function can log the error with its template position, attach the request ID from the context and render a friendly 500 component. Build it once and pass it as an option in the helper each router uses. The same goes for panics: recovery middleware belongs to the router layer (chi/middleware.Recoverer, gin.Recovery, echo's Recover), and they all work the same way with templ, since a panic inside a component unwinds through the handler like any other.
Middleware that renders, such as an authentication gate that shows a login page instead of calling next, can call templ.Handler(LoginPage()).ServeHTTP(w, r) directly with the standard writer and request. That is the quiet advantage of keeping components free of router types: any layer of the stack can render one, including the 404, the 429 and the maintenance page.
Practical advice
Pass plain values, not the framework context. A component taking name string works under any router and in a test with no router at all. A component that takes a *gin.Context ties your views to gin, which is what you used templ to avoid.
Share one helper per framework. In gin or echo, wrap the delegation in a function so handlers read like render(c, 200, Hello(name)). The helper is five lines, and it is the one place to add logging of render errors, request IDs or a custom error page through templ.WithErrorHandler.
Carry request data in the context. Middleware in any router can store the user, language or CSP nonce in the request's context.Context; components read it with ctx, as in layouts. Use the standard r.Context(), not a router-specific store such as gin's c.Set, because components cannot see those.
Test components without the router. Render into a string and assert on it, as in the testing article, and keep a few end-to-end tests per router for the routing behavior shown above: parameter decoding, 404 and 405.
Pick the router for the rest of your app. Nothing about templ favors one. If you are starting out, the standard mux with templ is the smallest combination and has no routing dependencies at all; chi adds groups and middleware stacks without changing handler types; gin and echo bring their own contexts, binding and middleware ecosystems. The component API is documented on pkg.go.dev, and the project guide at templ.guide has a section for each of these routers.


