How to mount custom HTTP handlers

Some paths do not fit a PK page (which renders HTML), a server action (which expects typed JSON), or a partial (which expects a fragment). XML feeds, OAuth callbacks, third-party webhooks, well-known files, machine-to-machine endpoints, and reverse-proxy paths all want a raw http.Handler. Piko exposes its underlying chi router for exactly this case. See about routing for when this is the right tool.

The router

SSRServer.AppRouter is a public exported *chi.Mux field. The router is chi. The full chi API is available without any wrapper.

import "piko.sh/piko"

func main() {
    ssr := piko.New(
        piko.WithWebsiteConfig(piko.WebsiteConfig{Name: "Example"}),
    )

    ssr.AppRouter.Get("/feed.xml", feedHandler)

    if err := ssr.Run(piko.RunModeProd); err != nil {
        log.Fatal(err)
    }
}

Register handlers before calling ssr.Run. Routes added after the server starts are subject to chi's standard goroutine-safety rules. They may not pick up middleware applied at startup. Treat the bootstrap phase as the registration window.

Serve an XML feed

func feedHandler(w http.ResponseWriter, r *http.Request) {
    items := database.GetItemsWithContext(r.Context())

    body, err := feed.Build(items)
    if err != nil {
        http.Error(w, "feed unavailable", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/xml; charset=utf-8")
    w.Header().Set("Access-Control-Allow-Origin", "*")
    w.Header().Set("Cross-Origin-Resource-Policy", "cross-origin")
    w.Write(body)
}

r.Context() carries the per-request context the rest of the runtime uses. Pass it through to your data layer.

Accept a webhook

Use Post for endpoints that receive payloads from external services:

ssr.AppRouter.Post("/webhooks/stripe", func(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "read body", http.StatusBadRequest)
        return
    }
    defer r.Body.Close()

    if err := stripeClient.VerifySignature(r.Header.Get("Stripe-Signature"), body); err != nil {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }

    if err := stripeClient.HandleEvent(r.Context(), body); err != nil {
        http.Error(w, "process event", http.StatusInternalServerError)
        return
    }

    w.WriteHeader(http.StatusNoContent)
})

Use URL parameters

chi's path parameters work directly:

ssr.AppRouter.Get("/api/v1/properties/{id}", func(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    // ...
})

Group routes with shared middleware

Use Route for path-prefixed groups, or Group plus Use for inline middleware:

ssr.AppRouter.Route("/api/v1", func(r chi.Router) {
    r.Use(authMiddleware)
    r.Use(rateLimitMiddleware)

    r.Get("/me", meHandler)
    r.Get("/properties", listProperties)
    r.Post("/properties", createProperty)
})

The middleware chain composes on top of any global middleware Piko already applies (CSRF, logging, request ID).

Mount a third-party handler

Use Mount to delegate a path prefix to another router or handler:

ssr.AppRouter.Mount("/admin", adminApp.Router())
ssr.AppRouter.Mount("/debug", middleware.Profiler())

Serve a static file

For one-off static responses (robots.txt, humans.txt, .well-known/*):

ssr.AppRouter.Get("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/plain; charset=utf-8")
    w.Write(robotsBody)
})

For a directory of static assets, use http.FileServer mounted with chi:

ssr.AppRouter.Mount("/.well-known/", http.StripPrefix("/.well-known/", http.FileServer(http.Dir("./well-known"))))

See also