When learning a new web framework, I like to avoid huge starter kits and focus on something small, useful, and easy to reason about.
That is exactly why I built a notes REST API with Go and Fiber. The goal was not to create a production-ready SaaS, but to understand the pieces that matter when starting with Fiber: routing, handlers, middleware, error handling, and a clean project structure.
Why Fiber ?
Fiber is attractive because it gives you a very approachable developer experience while still feeling fast and lightweight. It is inspired by Express, but built for Go, and it keeps the routing and middleware story simple enough that you can focus on your API design instead of fighting the framework.
For this project, I wanted something that would let me practice the basics of a REST API without adding unnecessary complexity. So instead of starting with a database, authentication, migrations, and Docker, I began with an in-memory notes store and a simple layered structure. That let me focus on the request flow first.
Project structure
One thing I did not want was a single main.go file with everything inside it. Even for a small API, separating concerns makes the code easier to read and evolve.
This is the structure I used:
fiber-notes/
├── main.go
├── handlers/
├── middleware/
├── models/
├── routes/
└── storage/
main.go wires the application together, the handlers contain the HTTP logic, routes register endpoints, models define the data shape, storage handles persistence, and middleware contains reusable request logic like logging.
Bootstrapping the Fiber app
The application setup is intentionally small. I create the Fiber app, initialize the memory store, inject it into the handler, register middleware, then mount the notes routes under /api.
app := fiber.New()
store := storage.NewMemoryStore()
noteHandler := handlers.NewNoteHandler(store)
app.Use(recover.New())
app.Use(middleware.SimpleLogger())
app.Get("/", func(c fiber.Ctx) error {
return c.JSON(fiber.Map{
"message": "Hello, World!",
"status": "ok",
})
})
api := app.Group("/api")
routes.RegisterNoteRoutes(api, noteHandler)
log.Fatal(app.Listen(":3000"))
Defining the model
The notes API only needs one model, and that simplicity is part of the exercise.
type Note struct {
ID int `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
This gives the API enough structure to feel real without becoming noisy. The current repo uses Title, Content, and timestamps, which already makes the responses clearer than a minimal { id, text } example.
Adding an in-memory store
To keep the project focused on Fiber itself, I used an in-memory store rather than a database. The store keeps notes in a map[int]models.Note, tracks the next ID, and uses sync.RWMutex so reads and writes stay safe. It exposes methods like List, GetById, Create, Update, and Delete.
That choice was useful for two reasons.
First, it made the project easier to understand. Second, it made the handler layer cleaner because the HTTP code does not need to care about how notes are stored. It only calls the store interface.
Here is the kind of constructor I used:
func NewMemoryStore() *MemoryStore {
return &MemoryStore{
notes: make(map[int]models.Note),
nextId: 1,
}
}
And here is the interface the handlers depend on:
type NoteStore interface {
List() []models.Note
GetById(id int) (models.Note, error)
Create(title, body string) models.Note
Update(id int, title, body string) (models.Note, error)
Delete(id int) error
}
That separation makes it easy to replace the memory store later with SQLite or PostgreSQL without rewriting the handlers.
Registering routes
Once the API group is created, the routes become straightforward:
func RegisterNoteRoutes(api fiber.Router, noteHandler *handlers.NoteHandler) {
notes := api.Group("/notes")
notes.Get("/", noteHandler.ListNotes)
notes.Get("/:id", noteHandler.GetByID)
notes.Post("/", noteHandler.Create)
notes.Put("/:id", noteHandler.Update)
notes.Delete("/:id", noteHandler.Delete)
}
This gives a clean REST shape:
GET /api/notes/GET /api/notes/:idPOST /api/notes/PUT /api/notes/:idDELETE /api/notes/:id
Writing the handlers
The handlers are where Fiber starts to feel pleasant. Each method receives a fiber.Ctx, reads route params or request bodies, validates input, calls the store, and returns JSON.
Here is the create handler pattern:
func (h *NoteHandler) Create(c fiber.Ctx) error {
if !c.HasBody() {
return fiber.NewError(fiber.StatusBadRequest, "request body is required")
}
var input CreateNoteInput
if err := c.Bind().Body(&input); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid input")
}
input.Title = strings.TrimSpace(input.Title)
input.Content = strings.TrimSpace(input.Content)
if input.Title == "" {
return fiber.NewError(fiber.StatusBadRequest, "title is required")
}
note := h.store.Create(input.Title, input.Content)
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"message": "note created successfully",
"data": note,
})
}
There are a few things I like here.
c.Bind().Body(&input) keeps request parsing readable. strings.TrimSpace gives quick input cleanup. And fiber.NewError(...) makes invalid states explicit.
For route parameters, I convert the id with strconv.Atoi(c.Params("id")). If the value is invalid, the handler returns a 400 Bad Request. If the note is not found, it returns a 404 Not Found. That small distinction already makes the API feel much more correct.
Adding middleware
I also wanted the project to include middleware from day one, because middleware is one of the best ways to understand how requests flow through Fiber.
The repo includes a simple custom logger:
func SimpleLogger() fiber.Handler {
return func(c fiber.Ctx) error {
start := time.Now()
err := c.Next()
status := c.Response().StatusCode()
log.Printf("%s %s %d %s", c.Method(), c.Path(), status, time.Since(start))
return err
}
}
This logs the HTTP method, path, status code, and execution time. It is small, but it already makes the server feel more like a real API. Alongside that, I added recover.New() so panics are intercepted and forwarded through Fiber’s error pipeline.
Why this project was useful
The biggest value of this project was not the notes domain itself. It was the repetition of the same backend fundamentals in a small space:
- structure the app
- register routes
- parse JSON
- validate input
- return proper status codes
- isolate storage from HTTP logic
- add middleware early
Because the project stays small, each layer remains easy to inspect. And because the app still performs real CRUD operations, it feels much closer to real backend work than a “Hello World” example.
Where to go next
This version uses in-memory storage on purpose, which means data disappears when the server restarts. That is fine for learning, but the natural next steps are:
- add SQLite or PostgreSQL
- write unit tests for the store and handlers
- add request validation
- introduce JWT authentication
- add pagination and filtering
- dockerize the app
That progression turns a simple learning project into a stronger backend foundation without changing the core structure too much.
Final thoughts
Fiber made this project enjoyable because it stayed out of the way. I could focus on HTTP concepts, project structure, and clean handler logic instead of spending too much time wiring infrastructure.
For anyone learning Go web development, building a small CRUD API like this is a great way to move from toy examples to something more realistic. And starting with notes is enough to learn the framework without getting buried in domain complexity.
Top comments (0)