Building a SaaS in Go without a single React component
Back to blog

Building a SaaS in Go without a single React component

·Dan Castrillo

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. 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. they're fine. but i'm a Go person, and this project was a good test of how far you can push server-rendered HTML in 2026. the answer: pretty far.

the stack

  • 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 compiles JSX-like syntax into Go functions. 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 queue lives in memory. if the server restarts, in-flight jobs are lost. but the jobs are already in Postgres. the sweeper runs every two minutes and re-enqueues anything stuck in PENDING or PROCESSING. eventual consistency 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

i don't miss state management. 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.

templ's type safety helps at scale. i have ~15 page templates. every one has typed parameters. you can't render a job detail page without passing the CSRF token, because it's a required argument to the function. refactoring feels safe.

once you need real-time collaborative editing or complex client-side state, you'll want a frontend framework. but for CRUD, dashboards, job queues, billing. server-rendered HTML with a sprinkle of HTMX works. it's fast, and the Go toolchain is a delight.

hrvst is at hrvst.donostia.ai. 100 free credits on signup. the API docs are there too.

Related Posts