why i didn't reach for Next.js
a few weeks ago i launched Hrvst — a pay-per-use web scraping API targeting Spanish-speaking markets. you submit a query, it extracts data from Google Maps, Amazon, Google Search, or runs an AI agent to find whatever you need. you pay per record, no subscriptions.
the interesting part isn't the scraping itself. it's that the whole thing — dashboard, auth, job queue, billing, API — runs as a single Go binary with no JavaScript framework. no Next.js, no React, no bundler. a server you can go build and ship.
i've shipped enough Next.js apps to know what i'm signing up for when i reach for one. they're fine. but i'm a Go person, and this project felt like a good excuse to see how far you can push server-rendered HTML in 2026. the answer is: pretty far.
the stack
the whole thing is:
- Go + Chi for routing and HTTP
- templ for type-safe HTML templates
- HTMX for the interactive bits (fragment swapping, polling, form submission)
- Tailwind for styles
- pgx talking directly to Postgres — no ORM
templ is the piece most Go people haven't encountered. you write components in a syntax that looks like JSX but compiles to Go functions. it's type-checked at compile time, which means a missing template parameter is a build error, not a runtime panic.
// templ component
templ JobCard(job *db.Job, csrfToken string) {
<div class="border border-border p-4">
<div class="text-sm font-bold">{ job.Name }</div>
<div class="text-xs text-muted-foreground">{ string(job.Status) }</div>
</div>
}
// used in a handler like any other function
func (a *App) JobDetail(w http.ResponseWriter, r *http.Request) {
job, _ := queries.GetJobByID(r.Context(), jobID, userID)
pages.JobDetail(u, job, auth.CSRFToken(r)).Render(r.Context(), w)
}the handler calls the template function. the template renders HTML. no state management, no hydration, no client bundle to ship. just bytes over the wire.
the job queue
Hrvst runs three types of workers concurrently: scraper workers (3), harvest agent workers (3), and a cleanup goroutine. no Redis, no Bull, no external queue. just Go channels.
type Queue struct {
scraperCh chan Job
hrvstCh chan HrvstJob
}
func New(scraperWorkers, hrvstWorkers int) *Queue {
q := &Queue{
scraperCh: make(chan Job, 100),
hrvstCh: make(chan HrvstJob, 100),
}
for range scraperWorkers {
go q.runScraperWorker()
}
for range hrvstWorkers {
go q.runHrvstWorker()
}
return q
}when a user submits a scrape job, the handler inserts it into Postgres (status: PENDING), then drops a message on the channel. a worker picks it up, calls the Newt API, marks it COMPLETED, deducts credits.
the simplicity here is a feature. the queue lives in memory, which means if the server restarts you lose any in-flight jobs. but the jobs are already in Postgres — the sweeper runs every two minutes and re-enqueues anything stuck in PENDING or PROCESSING. eventually consistent is fine for scraping jobs.
func (q *Queue) sweepStalledJobs(ctx context.Context) {
stalled, _ := queries.GetStalledJobs(ctx, config.C.NewtSweeperStaleThreshold)
for _, job := range stalled {
q.Enqueue(Job{ID: job.ID, UserID: job.UserID, ...})
}
}this pattern — channel-based queue with a DB-backed sweeper — handles everything you need for low-to-medium traffic without the operational overhead of a separate queue service. you only need Redis when the in-memory queue becomes the bottleneck, and that's a good problem to have.
htmx for the dynamic parts
most of the app is static HTML. but job status needs to update in real time, and the job list needs live search. HTMX handles both with almost no code.
polling for job status:
<div
hx-get="/jobs/{{ .Job.ID }}/status"
hx-trigger="every 3s"
hx-swap="outerHTML"
hx-target="this"
>
processing...
</div>when the server returns a fragment without the hx-trigger attribute, polling stops. that's the whole completion detection.
live search on the job list:
<input
type="search"
name="q"
hx-get="/jobs/content"
hx-trigger="input changed delay:300ms"
hx-target="#jobs-content"
hx-push-url="true"
/>300ms debounce, replaces the job list fragment, updates the URL. feels instant. no JavaScript written.
what surprised me
the biggest surprise was how much you don't miss state management when your server just... renders the right thing. the dashboard metrics, job status badges, credit balance — all computed fresh on every request. no cache invalidation, no stale UI, no optimistic updates that disagree with the server.
the second surprise was how much templ's type safety helps at scale. i have ~15 page templates. every one of them has typed parameters — you can't accidentally render a job detail page without passing the CSRF token, because it's a required argument to the function. it's the kind of constraint that makes refactoring feel safe instead of scary.
i'm not saying this approach is right for every SaaS. once you need real-time collaborative editing or complex client-side state, you'll want a frontend framework. but for most SaaS apps — CRUD, dashboards, job queues, billing — server-rendered HTML with a sprinkle of HTMX is genuinely good. it's fast, it's simple, and the Go toolchain is a delight.
Hrvst is at hrvst.donostia.ai if you want to poke around. 100 free credits on signup. the API docs are there too if you want to integrate it directly.