Monitoring websites for visual changes can be the basis of a micro-SaaS product – think of tools like Visualping that alert users when a page’s content or layout changes. In this tutorial, we’ll walk through creating your own website change alerts SaaS using Node.js and Express. Our app will periodically take screenshots of target webpages, compare them to previous snapshots, and notify users when differences are detected. We’ll cover everything from capturing screenshots (via Puppeteer or a screenshot API), storing and diffing images, setting up user accounts with Stripe subscription billing, scheduling recurring jobs, and sending out notifications. By the end, you’ll have a solid blueprint for a screenshot-based monitoring service and insight into scaling and maintaining it.

Architecture Overview

At a high level, our service consists of several components working together:

When a user signs up and subscribes to a plan, they can register one or more page URLs to track. The system takes an initial baseline screenshot of each page. On each scheduled run, a new screenshot is taken and compared to the last one. If a visual difference is found, the new image and a diff highlighting changes are saved, and the user is notified. If no significant change is found, we discard the new image (or update the baseline) to save storage. The backend continues this cycle as long as the user’s subscription is active .

Setting Up the Node.js/Express Backend

Begin by scaffolding a basic Express app (ensure you have Node.js installed). You’ll need a few primary routes and models. We’ll outline a simple data schema first:

In a SQL context, you might have tables like users, pages, and changes. For example, a minimal subscription tracking table could link a user to their plan and Stripe IDs . If using MongoDB or another NoSQL, similar fields can be stored in user documents.

Next, set up Express routes (we’ll flesh out details in later sections):

With the basic Express app and models in place, let’s move on to the core functionality: capturing screenshots and detecting changes.

Capturing Webpage Screenshots (Puppeteer or API)

Puppeteer is a popular Node library for controlling a headless Chromium browser. It’s ideal for our use case because it can load a page and capture a full-page screenshot easily . Install Puppeteer with npm:

npm install puppeteer

Then, to take a screenshot of a page, you can use code like this:

const puppeteer = require('puppeteer');

async function captureScreenshot(url, savePath) {
  const browser = await puppeteer.launch();
  try {
    const page = await browser.newPage();
    await page.goto(url, { waitUntil: 'networkidle2' });  // wait for page to load
    // Optional: set viewport for consistent dimensions
    await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 2 });
    await page.screenshot({ path: savePath, fullPage: true });
  } finally {
    await browser.close();
  }
}

This will save a PNG of the page at the given savePath. We set deviceScaleFactor: 2 for high-DPI (Retina) quality screenshots , and fullPage: true to capture the entire page. In production, you might also tweak Puppeteer launch options (like using headless: true which is default, or adding args to run in limited environments).

Using a Screenshot API: If you need to capture many screenshots or want to avoid managing Chrome instances (which can be memory-heavy), you can offload this to a third-party screenshot service. Services like ScreenshotOne, BrowserStack, or ScreenshotAPI provide simple HTTP endpoints or SDKs for capturing site images . For example, ScreenshotOne’s Node SDK lets you call an API instead of running Chrome locally. Using an API can simplify scaling (the API provider handles browser rendering) at the cost of API usage fees. For our tutorial, we’ll proceed with Puppeteer for control and then discuss scaling options later.

Storing Screenshots and Historical Data

Each time we capture a screenshot, we need to store it for comparison. You have a few choices here:

A practical strategy is to store only what’s necessary. For example, save the initial screenshot for a page and label it as the “baseline.” On each check, take a new screenshot and compare it to the baseline. If no change is detected, you can discard the new image (or replace the baseline with it, since they’re essentially the same). If a change is detected, then save the new screenshot (it becomes the latest baseline going forward) and possibly save a diff image or the old screenshot for reference. This way, you avoid accumulating identical images for every check interval . As Alfred Wang notes in his project, you only need to save an initial image and then save a new image only when there is a difference, rather than every time you check . This significantly reduces storage needs.

For file naming, include identifiers to avoid collisions. For instance: <pageId>-<timestamp>.png for the screenshot, and <pageId>-diff-<timestamp>.png for a diff image . This convention ensures unique names and makes it easy to trace which page and check time a file corresponds to.

If using cloud storage, you might upload with a key following a similar pattern. Here’s a snippet using AWS S3 (with aws-sdk):

const AWS = require('aws-sdk');
const s3 = new AWS.S3({ region: 'us-east-1' /* configure credentials */ });

const fileContent = require('fs').readFileSync(localImagePath);
const params = {
  Bucket: 'my-screenshot-bucket',
  Key: 'screenshots/' + fileName, // e.g. "screenshots/123-1618901234567.png"
  Body: fileContent,
  ContentType: 'image/png'
};
await s3.upload(params).promise();
// After upload, you can get the file URL via s3.getSignedUrl or use the S3 public URL if bucket is public.

Uploading the screenshot returns a Location URL that you can store in your database for later retrieval . In our design, the Page record could have a lastScreenshotUrl (or path) and similarly a lastScreenshotHash if we use hashing. If a change event is recorded, a Change record can store the URLs/paths to the “before” and “after” images and the diff image.

Note: If running everything on a single server (for early-stage simplicity), saving to local disk is fine – just ensure your backups or deployment strategy retains the images. For a more robust multi-server setup, prefer shared storage (S3 or a database if storing images as Base64, though file storage is more efficient for binary data).

Comparing Screenshots for Visual Differences

The core of our monitoring service is the visual diff between two screenshots. We’ll use pixel-by-pixel comparison to detect changes. A popular library for this in Node is pixelmatch, which is “the smallest, simplest and fastest JavaScript pixel-level image comparison library, originally created to compare screenshots in tests.” . Pixelmatch can compare two images and output the number of pixels that differ, and even generate a diff image highlighting those pixels.

Install pixelmatch (and pngjs, which helps read/write PNGs):

npm install pixelmatch pngjs

Using pixelmatch is straightforward. We read the old and new screenshot images into memory, then call pixelmatch on their pixel data:

const fs = require('fs');
const PNG = require('pngjs').PNG;
const pixelmatch = require('pixelmatch');

function compareScreenshots(imgPath1, imgPath2, diffPath) {
  const img1 = PNG.sync.read(fs.readFileSync(imgPath1));
  const img2 = PNG.sync.read(fs.readFileSync(imgPath2));
  const { width, height } = img1;
  const diff = new PNG({ width, height });
  
  const numDiffPixels = pixelmatch(
    img1.data, img2.data, diff.data, width, height,
    { threshold: 0.1 }  // 0.1 sensitivity threshold
  );
  
  if (numDiffPixels > 0) {
    fs.writeFileSync(diffPath, PNG.sync.write(diff));
  }
  return numDiffPixels;
}

In this code, numDiffPixels is the count of pixels that changed. The threshold option can be adjusted to ignore tiny differences; a lower threshold means more sensitive comparison . We create a diff image which will mark changed pixels (by default in bright pink). If numDiffPixels > 0, we save the diff image to diffPath. The return value tells us how many pixels changed – we can treat any nonzero value as “change detected,” or set a specific threshold (e.g. > 100 pixels) to filter out very minor shifts.

Pixelmatch usage in context: Suppose our scheduler captures a new screenshot to new.png and we have the previous baseline old.png. Running compareScreenshots('old.png', 'new.png', 'diff.png') will produce a diff and a count. For example, in a demo comparing a page before/after removing an element, pixelmatch reported Different pixels: 1875 and produced a diff image with highlights . If differences are found, it’s up to us what action to take. Typically, we will update our stored screenshot (the new image becomes the latest baseline) and notify the user of the change. As one guide notes, “If there is a difference, you can send an email, push notification or apply any logic you wish.” In our app, we’ll do exactly that – trigger an email or webhook to the user.

Scheduling Recurring Screenshot Checks

To continually monitor pages, we need to run the capture-and-compare process on a schedule. In Node, a simple approach is using the node-cron package or a similar job scheduler. This allows you to define cron expressions to run functions at regular intervals (e.g. every hour, every day at midnight, etc).

Install node-cron:

npm install node-cron

Then set up a cron job for screenshot monitoring. For example, to run a check every 4 hours, you could use a cron schedule like "0 */4 * * *" (meaning minute 0 of every 4th hour):

const cron = require('node-cron');

// Every 4 hours, run the monitoring job
cron.schedule('0 */4 * * *', () => {
  runMonitoringJobs();
});

In this snippet, runMonitoringJobs() would be a function that goes through all active pages for all users, and triggers the screenshot+diff process for each. For testing, you might use a faster schedule (e.g. every minute) , but in production you’ll choose a reasonable interval or make it configurable per plan.

Implementing runMonitoringJobs(): Fetch all pages that need checking (for example, query your database for pages that are active, possibly join with users to ensure their subscription is active). Loop through them and for each page:

  1. Determine the URL and last screenshot (or hash).
  2. Capture a new screenshot via Puppeteer.
  3. Compare it with the last one using pixelmatch (as shown above).
  4. If a change is detected:
    • Save the new screenshot and diff image (e.g. upload to S3 or save to disk).
    • Update the page’s lastScreenshotPath (or hash).
    • Create a Change record (if keeping history).
    • Trigger notifications (email/webhook) to the user.
    • (Optionally, if using hashes only for quick check: if hash differs, then you might run pixelmatch to generate a diff and confirm the extent of changes.)
  5. If no change is detected:
    • You might delete the new screenshot (since it’s same as old) to save space, or keep it and delete the old one. The approach from earlier suggests keeping only one copy of the latest image when there’s no change  .
    • No notification is sent.

Be mindful of performance: if you have many pages to check, opening multiple headless browser instances in parallel can strain your CPU/RAM. It may be wise to throttle or queue the jobs. Alfred Wang’s implementation initially ran all checks in parallel and noted that “Node runs all queries through an array.map, which runs many browser sessions simultaneously”, which can be a bottleneck . You can instead process pages sequentially or in small batches, or use a job queue library (like Bull or Bree) for more control. For a small MVP, running sequentially or a few at a time is fine.

Also, consider using separate worker process(es) for running the scheduled jobs, especially if jobs are heavy. In a micro-SaaS though, a single server might handle both the web app and background jobs initially. Just ensure the cron scheduling code runs when the app is live (you might put it in your main server startup script).

User Registration and Subscription Plans (Stripe Integration)

Now, let’s integrate user accounts and billing. We’ll use Stripe to handle subscription payments, which lets us focus on building the app while Stripe manages the hard parts of payments.

User Registration & Auth: Implementing a full user auth system is outside our scope, but assume users can register (perhaps with email/password) and log in to access a dashboard. You might use Passport.js or another auth library, or a simple JWT approach. The key is each user in our database will be linked to a Stripe customer and subscription. Ensure your User model has fields for stripeCustomerId, stripeSubscriptionId, and plan (or plan tier identifier).

Defining Plans: Decide on your pricing tiers – for example, Basic plan allows tracking 5 pages, Pro allows 20 pages, etc. In Stripe, you’d create corresponding products and prices (with monthly recurring prices). Each price can have a lookup key or name (like “basic_plan”) which you store as the user’s plan. For simplicity, you might hardcode the page limits in your code or config (e.g. a map of plan name to max pages). When a user tries to add a page, you’ll enforce that they haven’t exceeded their plan’s allowance.

Checkout and Subscription Creation: You can use Stripe Checkout for a quick way to create subscriptions. For example, your frontend could redirect the user to a Stripe Checkout session for the chosen plan. Stripe will handle collecting payment info and subscription setup, then redirect back to your site. Alternatively, you could integrate Stripe Elements or the Payment Intents API for more custom control. In either case, once the subscription is active, Stripe will generate events that we’ll catch via webhooks (next section).

On successful signup, you should save the Stripe customer ID and subscription ID in your database. Many implementations choose to let Stripe handle most logic and only save minimal data, using Stripe’s API to check status as needed . For instance, store the subscription ID and plan, and whenever you need to know if a user is active or how many pages they can track, refer to your plan definitions or Stripe metadata.

Enforcing Page Limits: When a logged-in user calls POST /api/pages to add a new page to monitor, check how many pages they already have and what their plan allows. If they exceed the limit, return an error or prompt them to upgrade. This is business logic you enforce in your app; Stripe itself won’t limit object counts for you – it’s up to you to honor the plan constraints.

Here’s a rough example of how you might enforce this in an Express route handler:

app.post('/api/pages', authMiddleware, async (req, res) => {
  const user = req.user;  // assuming authMiddleware sets req.user
  const { url } = req.body;
  // Get count of pages already monitored by this user
  const count = await Page.count({ where: { user_id: user.id } });
  const plan = user.plan;  // e.g. 'basic'
  const maxPages = PLAN_LIMITS[plan];  // e.g. { basic: 5, pro: 20 } defined somewhere
  if (count >= maxPages) {
    return res.status(403).json({ error: 'Plan limit reached. Upgrade to add more pages.' });
  }
  // If under limit, create new page record and take initial screenshot
  const page = await Page.create({ user_id: user.id, url, ... });
  // (Optionally trigger an immediate screenshot capture for baseline)
  res.json(page);
});

This ensures users only track what they pay for.

Now, let’s handle keeping the subscription status in sync using webhooks.

Handling Stripe Webhooks for Subscription Status

Stripe webhooks are crucial for a subscription-based SaaS. They allow Stripe to notify your backend of important events in real-time – such as a customer’s payment succeeding or failing, or a subscription being canceled. Our app needs to listen for these events and update user access accordingly (e.g. start or stop the monitoring jobs for that user).

Why webhooks? Because users might cancel or their payment method might expire without directly interacting with your app’s UI. Your system should respond to those changes as they happen. As a Stripe guide puts it: “When people subscribe, change or cancel their subscription, your system needs to listen for these events and update your database to grant or reject access to your service.” In other words, if a subscription payment succeeds, mark the user as paid/active; if a subscription ends or a payment fails, you might downgrade or suspend their monitoring.

Setting up the Webhook Endpoint: In your Express app, create a route for Stripe to POST events to, e.g. /api/stripe/webhook. You’ll need to disable the usual body-parser for JSON on this route (Stripe webhooks require the raw body for signature verification). For example, you could use app.post('/api/stripe/webhook', express.raw({type: 'application/json'}), (req, res) => { ... });. Inside, you’ll verify the Stripe signature (using your webhook secret from the Stripe dashboard) and then handle the event.

Handling Events: We care about a few key events:

Let’s sketch a simple webhook handler (omitting signature verification for brevity):

app.post('/api/stripe/webhook', express.raw({type: 'application/json'}), (req, res) => {
  let event;
  try {
    // Verify the signature if possible
    const sig = req.headers['stripe-signature'];
    event = stripe.webhooks.constructEvent(req.body, sig, STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    console.error('Webhook signature verification failed.', err);
    return res.status(400).send('Invalid signature');
  }

  // Handle the event
  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object;
      // e.g. retrieve subscription ID, customer ID from session
      // mark user as active in DB, store subscription ID
      break;
    }
    case 'invoice.paid': {
      // Subscription payment succeeded (initial or renewal)
      const invoice = event.data.object;
      // You can fetch customer or subscription ID from invoice
      // mark the user as active (if not already)
      console.log('Invoice paid for subscription:', invoice.subscription);
      break;
    }
    case 'invoice.payment_failed':
    case 'invoice.payment_action_required': {
      // Payment failed – subscription will go past_due
      const invoice = event.data.object;
      console.log('Payment failed for subscription:', invoice.subscription);
      // You might mark user subscription as past_due or send alert to user
      break;
    }
    case 'customer.subscription.deleted': {
      // Subscription cancelled or expired
      const sub = event.data.object;
      console.log('Subscription canceled:', sub.id);
      // find the user by stripeSubscriptionId and mark their plan as cancelled
      // maybe disable their monitored pages
      break;
    }
    default:
      console.log(`Unhandled event type: ${event.type}`);
  }

  res.sendStatus(200);
});

In the above pseudocode, we switch on event.type and handle what’s relevant. For instance, on customer.subscription.deleted, you might update the user’s record to reflect they no longer have an active subscription (thus you should stop their monitoring jobs). On invoice.paid, ensure the user is marked active (perhaps flip a flag like isActive=true on their subscription). On failures, you might set a flag or send an email urging them to update their card before cancellation happens.

Stripe’s official recommendations include listening to invoice.paid for successful payments and invoice.payment_failed for failed ones . The reasoning is that these cover recurring billing events. Our code above also logs checkout.session.completed to capture the very first signup if using Checkout.

Notice we verified the webhook signature – Stripe sends a header and you have a secret to validate the payload. This is important in production to ensure the request is truly from Stripe .

With our webhook handling in place, our app will maintain subscription state automatically. For example, if a user’s payment fails and Stripe cancels the subscription, the customer.subscription.deleted event will let us know to deactivate their monitoring. Conversely, if a user reactivates or a trial converts to paid, invoice.paid lets us enable their service.

Notifying Users of Visual Changes

One of the core values of our service is notifying users promptly when a webpage change is detected. We’ll implement two notification channels: email and webhook callbacks.

Email Notifications: To send emails from Node, you can use a service API (like SendGrid, Mailgun, etc.) or use SMTP via a library like Nodemailer. For simplicity, consider using an email-sending API with an npm package or a fetch call. For example, Alfred’s project used EmailJS – making a POST request to an API endpoint with the email details . In our case, we might use Nodemailer with a Gmail/SMTP or a transactional email service.

Here’s a quick example using Nodemailer (with Gmail SMTP):

const nodemailer = require('nodemailer');
const transport = nodemailer.createTransport({
  service: 'gmail',
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASS
  }
});

async function sendChangeEmail(toEmail, pageUrl, diffImageUrl) {
  const mailOptions = {
    from: '"Page Monitor" <[email protected]>',
    to: toEmail,
    subject: `Change detected in ${pageUrl}`,
    html: `<p>We detected a change in the page <a href="${pageUrl}">${pageUrl}</a>.</p>
           <p><strong>Screenshot difference:</strong></p>
           <img src="${diffImageUrl}" alt="diff image" />`
  };
  await transport.sendMail(mailOptions);
}

This would send an HTML email with a linked diff image. In production, you’d style the email nicely and perhaps include direct links to view the “before” and “after” screenshots.

If you prefer not to deal with SMTP, you could integrate an email API. For instance, using Fetch to call SendGrid’s API or using a library. The key is that in our monitoring loop, when numDiffPixels > 0, we invoke something like sendChangeEmail(user.email, page.url, diffImageLink).

Webhook Callbacks: Some users (especially other developers) might want to be notified via a webhook – essentially your system calling their API when a change occurs. This allows them to integrate the alert into their own systems (for example, posting to a Slack channel or triggering some script). To implement this, you can allow users to specify a webhook URL in their settings.

When a change is detected, similar to sending an email, you’d make an HTTP POST request to that URL with a JSON payload describing the event. For example:

const fetch = require('node-fetch');
async function sendWebhookNotification(webhookUrl, pageUrl, diffImageUrl) {
  const payload = {
    page: pageUrl,
    message: `Change detected in ${pageUrl}`,
    diffImage: diffImageUrl
  };
  try {
    await fetch(webhookUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload)
    });
  } catch (err) {
    console.error('Webhook notification failed:', err);
  }
}

You’d call sendWebhookNotification(user.webhookUrl, page.url, diffUrl) in the change-detection loop. It’s up to the user’s endpoint to handle the data. You might include an authentication token in the payload or as a header so the user can verify the request (or they could whitelist your IP, etc., depending on security needs).

Notification Content: In both email and webhook, include information that helps the user understand the change:

Remember to test your email sending (you can use a service like Mailtrap for safe testing) and webhook sending with a sample endpoint (even just a request bin or a temporary Express route that logs the payload).

Now that all core pieces are in place – capture, diff, notify, and the subscription gating – let’s outline how the system works end-to-end with sample routes and then discuss scaling.

End-to-End Flow with Sample Routes and Data Schema

Bringing it all together, here’s a typical workflow in our micro-SaaS:

  1. User Registration & Subscription: A user signs up (POST /api/register) and chooses a plan. They are directed to a Stripe Checkout session (POST /api/checkout-session which returns a Stripe Checkout URL). After successful payment, Stripe redirects them to our frontend, and behind the scenes our /api/stripe/webhook receives a checkout.session.completed or invoice.paid event. We update the user’s record to mark them active and store their plan and Stripe IDs. The user now has access to the monitoring features.

  2. Adding a Page to Monitor: The user logs into our frontend and enters a URL to monitor (this triggers POST /api/pages). Our backend checks the user’s plan limit and creates a new Page record with status active. Immediately, we capture a baseline screenshot for this page (this could be done synchronously in the request, or asynchronously in the background). The baseline image is stored (local or S3) and its path saved in the Page record. No notification is sent at this time since it’s the first capture.

  3. Recurring Monitoring Job: Our scheduler (cron job) runs say every 4 hours. It finds all active pages. For each page, it performs the steps: take new screenshot, compare with last stored screenshot.

    • If no differences, it may replace the old screenshot (or simply discard the new one and keep the old as baseline) , then move on.
    • If differences are found, it saves the new screenshot and a diff image highlighting changes. It updates the Page’s last screenshot reference to the new image. It records a Change entry (with pointers to images) if maintaining history.
    • The user associated with that page is identified, and a notification is sent: an email is dispatched to them, and/or a webhook is POSTed if they provided one. The content includes the page URL and perhaps a preview or link to the diff.

  4. User Notification and Action: The user receives the email (or webhook). They can now visit the site to see more details (if you build a front-end for it) – perhaps the dashboard shows a before/after image or lists recent changes. If the change is expected, they might do nothing; if not, they are glad for the heads-up. The monitoring continues.

  5. Subscription Change or Cancellation: If the user cancels their subscription or their payment fails, Stripe will send webhooks. Our /api/stripe/webhook catches customer.subscription.deleted or invoice.payment_failed. We update the user’s status in the DB (e.g. set subscription inactive or record the failure). Our monitoring job should check user status – e.g., ensure it only processes pages for users with active subscriptions. A canceled user’s pages might be marked inactive, so the scheduler skips them. You might also notify the user that their monitoring is paused due to billing issues.

  6. Plan Upgrade/Downgrade: If you allow upgrading plans, you’d handle that via Stripe (Stripe can handle prorations and such). On upgrade, maybe allow more pages. Ensure your code uses the latest plan info for limit checks. Stripe will send events for plan changes (like customer.subscription.updated) which you could handle similarly to update the plan field in your DB.

Sample Data Schema (Recap):

To illustrate, when a change is detected on page ID 42, a new row in changes might be inserted with page_id = 42 and paths to the images before and after. The user could view these via the app UI.

Scaling and Maintenance Considerations

Building a working prototype is only half the battle – running a screenshot monitoring SaaS in production comes with unique challenges. Here are some final thoughts on scaling and maintaining the system:

By covering these bases, you can turn your prototype into a reliable service. A micro-SaaS screenshot monitoring tool can start small (even a single server handling a few hundred pages), and with the considerations above, grow to a robust system handling thousands of pages and users. Remember to leverage the strengths of third-party services (Stripe for billing, possibly a screenshot API for heavy lifting, email services for deliverability) so you can focus on the unique value: detecting changes accurately and promptly for your users.

With this guide, a technical founder should have a strong starting point for building a website change alert SaaS. Happy coding, and good luck turning those page changes into useful alerts! 🚀

Sources: