Skip to main content

Migrate from Express (Node.js) to GoFr

Summary

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

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.

Mental model translation

ConceptExpress / Node.jsGoFr / Go
Async runtimeSingle-threaded event loop with awaitGoroutines + 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 parsingexpress.json() middlewarec.Bind(&struct)
Path paramsreq.params.idc.PathParam("id")
Query paramsreq.query.qc.Param("q")
JSON responseres.json(data)return data, nil
Error handlingnext(err)return nil, err
LoggingPino, Winston, BunyanBuilt into GoFr
Tracing@opentelemetry/instrumentation-expressBuilt into GoFr
Databasepg, mongoose, ioredisBuilt into GoFr (c.SQL, c.Mongo, c.Redis)

Hello world side-by-side

Express:

JavaScript
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:

Go
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()
}

Async patterns

In Node, you await a database call. In Go, you call the function directly — concurrency is provided by goroutines, not callbacks or promises.

Express:

JavaScript
app.get('/users/:id', async (req, res) => {
  const user = await db.getUser(req.params.id)
  res.json(user)
})

GoFr:

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

What you tend to gain

  • Static typing. Request bodies, response shapes, and DB rows are typed; many Express runtime errors disappear at compile time.
  • Concurrency. Goroutines + channels handle background work without async/await chains.
  • Single binary deploy. No node_modules, no runtime dependency on Node version.
  • Built-in production glue. Tracing, metrics, structured logging, datasource clients — Express requires you to assemble all of this.

Common gotchas

  • No callback-style error propagation. next(err) becomes return nil, err. Errors travel up the call stack; nothing happens implicitly.
  • No req.body mutation. Bind into a struct and mutate the struct.
  • Goroutines leak silently if you don't defer cleanup. A defer rows.Close() in your DB query is not optional in Go.
  • JSON shape is slightly different. GoFr wraps successful responses as {"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.

Estimated effort per service

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.

  1. Pick a small, isolated Node service to rebuild in GoFr (an internal tool, a webhook receiver).
  2. Match its endpoints 1:1.
  3. Run both side-by-side in your traffic split or as separate environments.
  4. Migrate larger services as your team builds confidence with Go.

Frequently asked