Skip to content
Go back

Astro Page View Tracking Without GA4 — Supabase

Updated:
Edit Views

Table of contents

Open Table of contents

Introduction

After setting up Astro 6, one question immediately came to mind: How do you track page views in Astro without Google Analytics or any third-party scripts?

This guide shows how to build a privacy-friendly analytics solution for Astro, using Supabase as a backend. You will be able to track page views, store them in Postgres, and avoid external analytics platforms completely.

At first, it looked like there should be many ready-made solutions. There were. But after reviewing them, most were either too complex, too dependent on third-party services, or simply not something I would trust long-term.

So I built my own version.

In this article, I’ll show a complete Astro page view counter powered by Supabase, with real database storage, Edge Functions, and a tiny frontend component.

What You Will Build

A minimal Astro analytics system that:

Existing Astro Views Counter Solutions

Naturally, the first thing I did was search for existing solutions. If someone already solved this cleanly, why reinvent it?

Most existing guides focus on basic page view counters, but few explain how to build a production-ready analytics backend for Astro.

Here are the most relevant implementations I found — and why none of them fully worked for me.

https://crockettford.dev/blog/astro-blog-views-counter

A fairly simple implementation. Good overall, but highly specific. Best suited for people already using Coolify and Docker.

It also introduces a less native database workflow (schema, connection, select, increment), which may be unnecessary complexity for a blog owner.

https://mvlanga.com/blog/how-to-build-a-page-view-counter-with-astro-db-actions-and-server-side-islands/

There’s a fair amount of unnecessary code here (in my opinion). A display component and two more for of updating views (Vanilla vs. React). How is one better than the other, and which should I ultimately use?

Also, the author didn’t explain the data storage layer. Where does Astro DB connect to, and where will the view data be stored?

https://elazizi.com/posts/add-views-counter-to-your-astro-blog-posts/

This is the most creative approach of the three and the simplest to set up.

However, it depends on two third-party services (corsproxy.io and hits.seeyoufarm.com). If one becomes unavailable, the counter stops working. That is a weak point for long-term use.

Search Results Summary

SolutionProsCons
crockettfordsimple setupstack-specific
mvlanganative approachrequires Astro DB knowledge
elazizicreative solutionthird-party dependency

None of these options fully matched what I wanted.

The closest was the implementation from mvlanga, but it does not fully cover real database setup and production usage.

So I decided to build my own version: simple, practical, and independent Astro page views powered by Supabase.

The Plan

We’ll use Supabase Postgres for storage, an Edge Function as the backend layer, and a tiny Astro component on the frontend. The key design decision: all mutation logic stays in the Edge Function, not in the browser. This keeps the public surface area minimal — the component only calls one endpoint and renders one number.

Open page Load client script Request views count Query / update views Return views data JSON response Render views Show page with views Visitor Astro Page JS Fetch Supabase Edge Function Postgres
E2E workflow

This approach effectively creates a serverless analytics pipeline for tracking page views in Astro.

Implementation

If you don’t have an account with Supabase yet, I’d recommend creating one, confirming your email, and creating your first project. Leave all the settings at default, we don’t need that right now. After creating the project, wait a few minutes for initialization.

Create Database

This part takes only a few minutes and gives you permanent storage for all page views. The schema is optimized for a simple page view tracking system, where each page is identified by its slug.

Open SQL Editor and paste the next script:

create table public.views (
  id bigint generated by default as identity not null,
  created_at timestamp with time zone not null default now(),
  slug text not null,
  views bigint not null default '0'::bigint,
  constraint views_pkey primary key (id),
  constraint views_slug_key unique (slug)
) TABLESPACE pg_default;

What this table gives you: slug is unique — so each page has exactly one row. views starts at 0 and is incremented atomically by the Edge Function. No application code manages row creation, the UPSERT in the function handles both insert and update.

See the Supabase Table Editor docs if you prefer a visual interface over SQL.

Configure Access Policy

After creating the table, configure access.

Open: Authentication → Policies

For table views, create policy:

All other settings remain unchanged.

Edge Function

Now comes the useful part. Supabase Edge Functions run on Deno and are deployed globally. We need single public endpoint that:

  1. receives a page slug
  2. creates the row if missing
  3. increments views atomically
  4. returns the latest count

Next Edge Function acts as a page view tracking API. It replaces traditional analytics collectors like Google Analytics by handling view counting directly in your backend.

Open: Edge Functions then find the buttons Deploy a new function → Via Editor and paste the next code:

// Setup type definitions for built-in Supabase Runtime APIs
import 'jsr:@supabase/functions-js/edge-runtime.d.ts';
import { Pool } from 'jsr:@db/postgres';

const pool = new Pool(Deno.env.get('SUPABASE_DB_URL')!, 3, true);

Deno.serve(async (req: Request) => {
  const corsHeaders = {
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Methods': 'OPTIONS, POST',
    'Access-Control-Allow-Headers': 'x-client-info, apikey, content-type',
    'Content-Type': 'application/json',
  };

  if (req.method === 'OPTIONS') {
    return new Response(null, {
      status: 204,
      headers: corsHeaders,
    });
  }

  if (req.method !== 'POST') {
    return new Response(JSON.stringify({ error: 'Method not allowed' }), {
      status: 405,
      headers: corsHeaders
    });
  }

  try {
    const { slug } = await req.json();

    if (!slug) {
      return new Response(JSON.stringify({ error: 'slug is required' }), {
	      status: 400,
	      headers: corsHeaders
	    });
    }

    const db = await pool.connect();

    try {
      const result = await db.queryObject<{ views: string }>(
        `
	        insert into views (slug, views)
	        values ($1, 1)
	        on conflict (slug)
	        do update
	        set views = views.views + 1
	        returning views::text as views
        `,
        [slug]
      );

      const [row] = result.rows;

      return new Response(JSON.stringify(row.views), {
        status: 200,
        headers: corsHeaders,
      });
    } finally {
      db.release();
    }
  } catch (e) {
	  const error = e instanceof Error ? e.message : 'Something went wrong';

    return new Response(JSON.stringify({ error }), {
      status: 500,
      headers: corsHeaders,
    });
  }
});views

Function: What to customize

Function: How it works

At the very bottom of the page in the Function name field, enter the name of your function views and click Deploy Function. You will then be redirected to the function settings page.

Function: Verification

At the very beginning of the function page you’ll notice the address of your function, https://.../functions/v1/views follow that link and you should receive the following error:

{ "error": "Method not allowed" }

Excellent, exactly what we need!

This indicates that the function is actually working and rejecting incoming GET requests, because in the code we explicitly specified only OPTIONS and POST requests.

This completes the server-side work.

If you’d like, you can play around with this request using tools like Postman or Apidog.

Astro Component

This component is responsible for sending a request from the client and updates the counter without blocking rendering.

Create: Views.astro

---
import IconEyeIcon from "@/assets/icons/IconEye.svg";

type Props = {
  slug: string;
};

const { slug } = Astro.props;
---

<span class="inline-flex items-center gap-x-2 opacity-80">
  <IconEyeIcon />
  <span class="sr-only">Views</span>
  <span id="views">…</span>
</span>

<script define:vars={{ slug }} is:inline data-astro-rerun>
  (() => {
    if (!slug) return;

    const el = document.getElementById("views");

    if (!(el instanceof HTMLElement)) return;

    const endpoint = "https://hash.supabase.co/functions/v1/views";

    const render = value => {
      el.textContent = new Intl.NumberFormat().format(Number(value));
    };

    const fallback = () => {
      el.textContent = "…";
    };

    const load = async () => {
      try {
        const res = await fetch(endpoint, {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({ slug }),
          keepalive: true,
          credentials: "omit",
          cache: "no-store",
        });

        if (!res.ok) {
          fallback();
          return;
        }

        const value = await res.json();

        render(value);
      } catch {
        fallback();
      }
    };

    if ("requestIdleCallback" in window) {
      requestIdleCallback(load, {
        timeout: 1000,
      });
    } else {
      setTimeout(load, 0);
    }
  })();
</script>views.astro

Component: What to customize

Component: How it works

requestIdleCallback schedules work during browser idle time. MDN has a full reference if you need it.

Once you’ve created the view counter component, you can use it as follows: declare the component’s import, add it to your layout, and pass the current page’s id to it.

import Views from "@/components/Views.astro";
...
<Views slug={post.id} />layout.astro

Prevent Fake Views

Right now every refresh counts as a new view. The simplest mitigation to prevent fake views and that requires no extra dependencies: add a sessionStorage check in the Astro component so the same browser tab only counts once per session.

Add this at the top of the load function:

if (sessionStorage.getItem(slug)) return;views.astro

Add this after a successful fetch:

sessionStorage.setItem(slug, "1");views.astro

Prevent cheating during development, add this at the top of the load function:

if (import.meta.env.DEV) return;views.astro

This is a basic anti-spam mechanism for your tracking system. It won’t stop bots or multi-tab visits, but it eliminates accidental self-inflation during development. That may be perfectly fine for a personal blog. But if you want cleaner numbers, add:

Use the level of accuracy your project actually needs.

Self-Hosted Page Views vs Analytics Platforms

If you are looking for a GA4 alternative for Astro, and only need to track page views, tools like Google Analytics or Plausible are simply overkill. They load external scripts, add latency, and hand your visitor data to a third party.

This approach is a form of privacy-first analytics, where no user data is shared with third parties and keeps everything under your control:

FAQ

Does this work with Astro static output mode? Yes. The view counter uses a client-side fetch call, so it works with fully static Astro output. No server-side rendering required.
Will this count my own visits during development? Yes, by default. To exclude yourself, either add an IP-based cooldown in the Edge Function or simply ignore the count until you deploy.
Is this a replacement for analytics platforms? No. This only tracks raw page views per slug. It has no referrer data, session tracking, bounce rates, or geography.

Conclusion

You now have a complete self-hosted analytics system for Astro — it lives in your Postgres table, increments atomically, and costs nothing beyond your Supabase plan. No third-party scripts. No data leaving your stack.

For a blog that just wants to know what people are reading, that’s the whole game.


Share this post on:

Previous Post
Docker Base Images — bookworm-slim, alpine, distroless, and Chainguard Wolfi