From NextJS to Go: A Journey of Simplicity and Speed

Go NextJS Web Development

Why I Migrated from NextJS/Supabase to Go

Have you ever built something that works perfectly in development, only to watch it crumble under real-world usage? That’s exactly what happened with my side project, Announcable.

It all started with a practical need at Locaboo, where I lead the product team. We needed a simple, customizable solution for in-product release notes without the bloat of existing tools. When I couldn’t find anything suitable, I built it myself with the common tech stack for these things: NextJS and Supabase.

Development was smooth sailing – the app came together quickly, with NextJS and Supabase doing most of the heavy lifting. But reality hit hard after deployment.

Pages that loaded instantly in development now took seconds in production. Features that felt snappy during testing became sluggish in real use. Worse yet, I started receiving unexpected notifications: “You have reached your image processing limit” from Vercel and “You have exceeded the number of edge functions allowed in the free tier” from Supabase.

I hadn’t even officially launched, and my “lightweight” side project was threatening to cost €50 monthly just to operate. That’s when I decided to completely transform my approach: Migrating the entire application to Go with simple HTML templates backed by HTMX and Alpine, hosted on my own VPS. And while I was on it, I decided to also build my own authentication system from scratch.

Why make such a dramatic change? Not because NextJS and Supabase are flawed – they’re fantastic tools – but because they weren’t right for my specific situation. Performance issues were the first red flag. I wanted Announcable to be fast, and in production, it simply wasn’t delivering.

The unexpected costs were the real wake-up call. Image processing was particularly painful – Vercel charges after a certain threshold, which I hit almost immediately. Similarly, Supabase’s limits meant I was constantly bumping against ceilings I hadn’t anticipated.

There was also curiosity pulling me toward Go. After years in TypeScript ecosystems, I was perhaps too comfortable. Many developers I admired spoke enthusiastically about Go’s simplicity, performance, and thoughtful standard library.

Sometimes, the most rewarding journeys begin with a step into the unknown.

Learning Go

My first step was to actually learn Go. The official Go resources became my constant companion, my rough order of reading was:

What struck me immediately about Go was its directness. There’s a refreshing clarity to Go code that I hadn’t fully appreciated before. Where JavaScript often offers a dozen ways to accomplish the same task, Go typically presents one or two straightforward approaches. This “less is more” philosophy initially felt constraining but quickly became liberating.

A new word I learned along the way: Idiomatic. Most of the resources mentioned above offer lots of great examples on how to write “idiomatic” Go code. This emphasis on patterns made Go particularly easy to get into. Now I sometimes even ask my self what the “idiomatic” way of all others sorts of things in life should be.

Rolling my own auth

Building my own authentication system was perhaps the boldest decision in this migration. Authentication is notoriously difficult to get right, with security implications that can make even experienced developers nervous. But I was tired of wrestling with Supabase Auth’s abstractions and the debugging nightmares that came with them.

So I started from first principles: What does authentication actually need to do? It needs to

  • securely store user credentials
  • generate and validate tokens
  • manage sessions

On my initial Googling frenzy, two particularly useful resources emerged:

Using these two resources, creating my own auth from scratch was’t all that complicated. Like with most “complicated” things in life my experience was like this: Once you break something complicated down into manageable parts and take the time to really understand each of them, the thing becomes actually quite straight forward.

For my minimal authentication system, I implemented:

  • A session service for creating, storing and receiving sessions connected to a user
  • Middleware to authenticate a user using this service
  • Various protections against malicious attacks, in particular rate limiting and CSRF protection

Was it more work than using an auth provider? Absolutely. But the control and understanding I gained were invaluable. When something didn’t work, I knew exactly where to look. When I needed to customize a flow, I didn’t have to hope the provider supported my use case—I could just implement it myself.

Project structure and patterns

The next major challenge was restructuring the application architecture. In NextJS, the lines between client and server code can blur, especially with features like Server Components. In Go, these boundaries are crystal clear: your server handles requests, processes data, and returns responses.

I settled on a fairly standard Go web application structure:

  • Handlers to receive HTTP requests
  • Services to implement business logic
  • Repositories to interact with the database
  • Models to define data structures
  • Templates to render HTML

For routing, I chose Chi — a lightweight router that follows Go’s standard library patterns. For validation, I used the aptly named validator package. And for handling form data, Gorilla’s schema package proved invaluable.

Eventually, a common pattern emerged for basically every handler. Is it a form or some kind of other action?

  1. Define request structure -> Simple struct
  2. Parse request -> http standard library
  3. Decode request -> Gorilla schema
  4. Validate request -> Go playground validator
  5. Execute business logic -> Domain services
  6. Assemble and return response

Is it a page to display?

  1. Execute business logic -> Domain services
  2. Assemble input for page template
  3. Serve template -> html template standard library

The most challenging aspect was replacing React’s component-based UI with Go’s template system. Go templates are powerful but fundamentally different from JSX. They don’t have the same state management capabilities or component abstraction. I had to rethink how I structured my UI, breaking pages down into reusable template partials and leveraging Go’s template inheritance.

After a few weeks of focused work, I had a functioning version of Announcable running on Go. The moment of truth came when I deployed it to my Hetzner VPS and pointed my domain to the new server.

And that’s when I saw the results of all this work — results that would completely change my perspective on web development.

Key Discoveries and Surprises

The first time I loaded the new Go version of Announcable, I actually thought something was wrong. The page appeared so quickly that I assumed it hadn’t loaded properly. I refreshed, and again — instant response. This wasn’t the behavior I had grown accustomed to with my NextJS application.

It’s crazy how fast the application is now. The difference isn’t subtle — it’s transformative. Pages that took seconds to load now appear almost instantly. Actions that felt sluggish now happen with no perceptible delay. Sometimes I just click around the application, marveling at its responsiveness. I knew static sites could be this fast, but a dynamic web application with authentication, database queries, and complex business logic? That was a revelation.

Building my own authentication system turned out to be far less daunting than I had anticipated. Yes, it took longer initially than simply plugging in Supabase Auth, but the resulting system is stable, secure, and — most importantly — completely under my control. When I need to modify authentication flows or add new features, I don’t have to navigate complex documentation or hope that my use case is supported. I just modify my own code.

One of the most significant revelations was how simple web development can be when you strip away layers of abstraction. In NextJS, I was constantly thinking about client components versus server components, server actions, data fetching strategies, and the complex interplay between client and server state. In Go, the model is refreshingly straightforward: receive a request, process it, return a response. This clarity made debugging easier, development faster, and the codebase more maintainable.

Perhaps most surprisingly, I found that pointing AI tools at my Go handlers and asking them to create new handlers in the same style worked reliably almost every time. The consistency and clarity of Go’s approach to web development made it much easier for AI to understand and extend my codebase compared to the complex abstractions of NextJS.

These discoveries weren’t just technical wins – they changed how I think about web development. They reminded me that sometimes, the most sophisticated solution isn’t the one with the most features or the latest abstractions, but the one that does exactly what you need with minimal complexity.

Reflections on Framework Abstractions

Those few weeks of working with Go fundamentally changed how I view modern web frameworks. I was implementing a new feature in Announcable, following the now-familiar pattern: routing → parsing → schema validation → business logic → view templates. The code was clean, the logic was clear, and the feature worked perfectly.

And you know what? This is what NextJS is doing under the hood, just with layers upon layers of abstractions added on top.

All those concepts that seemed so essential in the React ecosystem—client components, server components, server actions, hooks, context, reducers—they’re just abstractions over this fundamental request-response cycle. And while these abstractions can be powerful, they also introduce complexity, obscure what’s actually happening, and sometimes create more problems than they solve.

To be fair, this isn’t just a NextJS issue—it’s a trend across modern frontend development. We’ve built towers of abstractions so tall that we’ve lost sight of the foundation.

Don’t get me wrong—React and NextJS are powerful tools that solve real problems, especially for large teams building complex applications. But for many projects, including Announcable, these abstractions were overkill. They were solutions to problems I didn’t have, adding complexity without corresponding benefits.

Working with Go’s simple template system was both limiting and enlightening. Yes, there were moments when I missed React’s component model and state management. Creating dynamic interfaces with Go templates requires more manual DOM manipulation and careful thinking about state changes. You can definitely build very dynamic and nice-looking applications (as I did with Announcable), but there’s no denying that some interactions are more challenging to implement.

But this limitation had an unexpected benefit: it forced me to learn web fundamentals in a deeper way. Without useState and useEffect to lean on, I had to think carefully about how to structure my HTML, CSS, and JavaScript. I had to understand browser events, form submissions, and AJAX requests at a more fundamental level. And this understanding made me a better developer, even if I eventually return to React for certain projects.

Not everything about the Go ecosystem was perfect, though. GORM, the ORM I chose for database interactions, sometimes felt like the last vestige of unnecessary abstraction in my stack. While it simplified many common database operations, it occasionally behaved in ways I found surprising or illogical. There were moments when I wished I had just used raw SQL queries instead of wrestling with GORM’s abstractions.

This experience made me realize something important about abstractions in software development: they’re tools, not mandates. The best developers know when to use high-level abstractions and when to work closer to the metal. They understand what’s happening beneath those abstractions and can drop down a level when needed.

Before this migration, I was comfortable with the abstractions provided by the React ecosystem but had limited understanding of what was happening underneath. Now, having built the same application both ways, I have a much clearer picture of the tradeoffs involved.

Will I use React and NextJS again? Almost certainly. They’re still excellent tools for certain types of projects. But I’ll use them with a more critical eye, always asking whether their abstractions are solving problems I actually have or just adding complexity I don’t need.

And for some projects, I’ll choose Go from the start, embracing its simplicity, performance, and clarity. Because sometimes, the best abstraction is no abstraction at all.

Conclusion

When I started the migration from NextJS to Go, I was motivated by practical concerns: performance issues, unexpected costs, and a desire to learn something new. What I didn’t anticipate was how profoundly this experience would change my approach to web development.

The new version of Announcable isn’t just faster and more cost-effective—it’s better in almost every way. It’s more reliable, more maintainable, and provides a superior user experience. The code is clearer, the architecture is simpler, and when issues arise, they’re easier to diagnose and fix.

But beyond these tangible benefits, this migration taught me valuable lessons that will influence every project I work on going forward:

  • Simplicity is underrated. In an industry that often celebrates complexity and cutting-edge features, it’s easy to forget the value of simple, straightforward solutions. Go’s approach to web development reminded me that sometimes, the most elegant solution is the one with the fewest moving parts.
  • Understanding fundamentals matters. Working closer to the metal forced me to deepen my understanding of how the web actually works. This knowledge is transferable across frameworks and languages, making me more adaptable and effective regardless of the tools I’m using.
  • Control has value. Building and hosting everything myself gave me complete control over my application. This control comes with responsibility, but it also means I’m never at the mercy of a third-party service’s limitations, pricing changes, or design decisions.
  • Performance is a feature. The dramatic speed improvement in the Go version of Announcable wasn’t just a technical win—it fundamentally changed how the application feels to use. Never underestimate the impact of performance on user experience.

So, what does this mean for you?

If you’re building a new project, I encourage you to question whether you really need all the abstractions provided by modern frameworks. Could a simpler approach work just as well or better? Are you solving problems you actually have, or are you adopting complexity because it’s the current trend?

If you’re comfortable in one technology stack, consider stepping outside your comfort zone. Learn a language or framework with a different philosophy. Even if you return to your preferred tools, you’ll bring back valuable insights and approaches.

And if you’re facing performance issues or unexpected costs with your current stack, know that alternatives exist. Sometimes, the best solution isn’t optimizing what you have—it’s rethinking your approach entirely.

As for Announcable, it continues to evolve. The migration to Go has given it a solid foundation for growth, and I’m excited to see where it goes from here. What started as a solution to a specific problem at Locaboo might become something much more.

And personally, I’m grateful for this journey. It’s reminded me why I fell in love with programming in the first place: the joy of building something from the ground up, understanding how it works at every level, and seeing it come to life exactly as I envisioned.

Sometimes, the best way forward is to take a step back—to strip away layers of abstraction and reconnect with the fundamentals of what we’re trying to build. In doing so, we might just discover that simplicity isn’t a limitation—it’s a superpower.

Imprint

This website is created and run by

Daniel Benner

Zur Deutschen Einheit 2

81929 München

Germany

hello(a)danielbenner.de