DEV Community

Haimanot Getu
Haimanot Getu

Posted on

I built a bootstrapped uptime and competitor intelligence SaaS for eCommerce

I run a small side project called Beaconmon. It monitors websites for uptime, tracks competitor pricing and promotions, and sends intelligence digests to eCommerce store owners. Shopify and WooCommerce are the primary target.

Here is how it is built and the decisions that shaped it.

The problem

Most uptime tools are overkill for a small eCommerce store. A Shopify merchant does not need Datadog. They need to know when their store goes down, when a competitor drops prices before a sale weekend, and when a checkout page breaks.

The interesting constraint: competitor tracking is not just a nice-to-have. It is the primary reason someone would pay for this over a free tier of Pingdom.

Tech stack

  • Next.js 15 (App Router) for the dashboard and the marketing site, combined in one app
  • TypeScript everywhere in a pnpm monorepo
  • PostgreSQL + Prisma for data, with a separate workers process for background jobs
  • BullMQ + Redis for the job queue
  • better-auth for authentication (sessions, OAuth)
  • Freemius as the merchant of record (not Stripe, more on that below)
  • Resend + React Email for transactional and lifecycle emails
  • Sentry + PostHog for error tracking and analytics
  • Docker Compose for both local dev and production on a single VPS

The monorepo has two apps and several packages:

apps/
  web/       # Next.js: dashboard + marketing
  workers/   # Background job workers
packages/
  checker/   # Core check evaluation logic
  queue/     # BullMQ queue definitions
  db/        # Prisma schema + repositories
  types/     # Shared TypeScript types
  billing/   # Freemius integration
  ai/        # AI provider abstraction (noop by default)
  logger/    # Structured logging (Pino)

Enter fullscreen mode Exit fullscreen mode

Three architectural decisions worth talking about

1. Two consecutive failures before marking a monitor down

False positives are the biggest trust killer in uptime monitoring. A single timeout from a flaky CDN node will fire a 2am alert and destroy your credibility with the user.

The evaluation logic in packages/checker/ requires two consecutive failures before transitioning a monitor to down. On the first failure it records the check result but holds the incident open. If the next check passes, it closes clean. If it fails again, it fires.

This cut false positive alerts to near zero in testing against real Shopify stores.

2. Status pages read from Redis, not Postgres

Status pages are ISR with a 30-second revalidate. When a store goes down, the merchant's customers hit the status page simultaneously. That is easily 100 to 300 requests per minute on a small VPS.

If that read hits Postgres directly, the database becomes the bottleneck at exactly the worst moment. Instead, the workers write incident state to Redis on every check, and the status page reads from there. Postgres is never in the critical path during an incident.

3. Content monitoring with Cheerio, no Playwright

The competitor tracking feature diffs HTML content over time to detect price changes and promotional banners. The obvious tool is a headless browser, which handles JavaScript-rendered content perfectly.

I do not run Playwright in the worker fleet. The entire production setup runs on a single VPS. A Playwright worker pool would consume 2 to 4 GB of RAM and make the whole deployment fragile.

Cheerio handles the vast majority of eCommerce content just fine, JavaScript-rendered content is explicitly out of scope, and RAM stays predictable.

The billing choice: Freemius over Stripe

Freemius is a merchant of record for software. They handle VAT, sales tax, and payment processing. For a bootstrapped solo project, not dealing with tax compliance in 50 US states and the EU is worth the higher fee.

The tradeoff: Freemius has a clunkier API and their SDK is less polished than Stripe's. I wrapped it in a packages/billing package so the rest of the codebase does not care which billing provider is underneath.

One plan ID per tier, not two (monthly/annual). Freemius handles the billing cycle internally as an integer (1 = monthly, 12 = annual).

The workers

The workers app runs a set of specialized BullMQ workers:

  • check-worker runs HTTP checks on a schedule
  • content-worker diffs HTML snapshots for content monitoring
  • competitor-page-worker fetches competitor pages
  • alert-worker sends notifications through configured channels (email, Slack, Discord, SMS, webhook)
  • digest-worker generates intelligence digests
  • scheduler enqueues jobs at the correct intervals

The scheduler is pinned to one replica. BullMQ uses Redis for job storage with noeviction policy and AOF durability. Job data must not be evicted.

Alert sends are idempotent: before sending any notification, the worker checks for an existing send for the same (incident_id, channel_id) within the last 5 minutes.

This handles the case where a worker crashes mid-send and retries.

Tenant isolation

Every web-facing database function takes teamId as its first argument.

No exceptions.

This is enforced by convention and code review, not by row-level security at the database layer. Worker repositories are exempt because job payloads are already tenant-scoped at enqueue time.

Where it is now

Beaconmon is live.

The onboarding wizard detects Shopify and WooCommerce stores at step one and bulk-creates the competitor monitors at the end of the flow rather than incrementally.

If you are curious about the codebase or have questions about any of these decisions, drop them in the comments.

Built with Next.js, BullMQ, Prisma, and too much Redis.

Top comments (0)