docs/migrate/from-express/page.md
{% answer %}
Coming from Express to GoFr is more than a framework migration — it's a language change. The mental model translates well: routing, middleware, request/response, and async I/O all have direct Go equivalents. Handlers go from (req, res) => res.json(data) to func(c *gofr.Context) (any, error) { return data, nil }.
{% /answer %}
{% callout title="Migrating with an AI assistant?" %} Hand https://gofr.dev/AGENTS.md to your coding assistant (Claude Code, Cursor, Codex, Aider). It contains the framework conventions, routing/binding/datasource patterns, and per-framework cheat-sheets so the assistant can translate handlers without you re-explaining GoFr. {% /callout %}
| Concept | Express / Node.js | GoFr / Go |
|---|---|---|
| Async runtime | Single-threaded event loop with await | Goroutines + channels (true concurrency) |
| Request handler | (req, res, next) => {} | func(c *gofr.Context) (any, error) |
| Middleware | (req, res, next) => next() | func(http.Handler) http.Handler |
| Body parsing | express.json() middleware | c.Bind(&struct) |
| Path params | req.params.id | c.PathParam("id") |
| Query params | req.query.q | c.Param("q") |
| JSON response | res.json(data) | return data, nil |
| Error handling | next(err) | return nil, err |
| Logging | Pino, Winston, Bunyan | Built into GoFr |
| Tracing | @opentelemetry/instrumentation-express | Built into GoFr |
| Database | pg, mongoose, ioredis | Built into GoFr (c.SQL, c.Mongo, c.Redis) |
Express:
import express from 'express'
const app = express()
app.use(express.json())
app.get('/hello', (req, res) => {
res.json({ message: 'Hello, world' })
})
app.listen(8000)
GoFr:
package main
import "gofr.dev/pkg/gofr"
func main() {
app := gofr.New()
app.GET("/hello", func(c *gofr.Context) (any, error) {
return "Hello, world", nil
})
app.Run()
}
In Node, you await a database call. In Go, you call the function directly — concurrency is provided by goroutines, not callbacks or promises.
Express:
app.get('/users/:id', async (req, res) => {
const user = await db.getUser(req.params.id)
res.json(user)
})
GoFr:
app.GET("/users/{id}", func(c *gofr.Context) (any, error) {
return db.GetUser(c.PathParam("id"))
})
The c (Context) carries deadline and cancellation just like JavaScript's AbortController, but is automatically propagated to all DB and HTTP calls.
node_modules, no runtime dependency on Node version.next(err) becomes return nil, err. Errors travel up the call stack; nothing happens implicitly.req.body mutation. Bind into a struct and mutate the struct.defer cleanup. A defer rows.Close() in your DB query is not optional in Go.{"data": ...}. If Express clients expect the raw object, return a wrapper.process.env becomes app.Config.Get(key). Configuration is loaded from .env files in the configs/ directory by default.A small Express service (10-20 routes, light DB usage) typically takes 2–4 engineering days for a developer new to Go. Most of the time goes to learning Go idioms (error handling, struct composition) rather than the framework itself.
{% faq %}
{% faq-item question="Will my JSON contracts change?" %}
GoFr wraps successful responses as {"data": ...} by default — and a plain struct returned from a handler is always wrapped. If your existing Express clients expect a different envelope (or no envelope), return one of GoFr's special response types instead: response.Raw{Data: …} writes the value directly with no envelope, and response.Response lets you control the shape. The wrapper is only bypassed when you return one of these typed responses, not when you return an arbitrary struct.
{% /faq-item %}
{% faq-item question="What about NestJS or Fastify users?" %} NestJS users will find GoFr's structured approach familiar (controllers map to handlers, modules to packages). Fastify users will appreciate the lower runtime overhead. {% /faq-item %}
{% /faq %}