How to build a view counter in Astro: SSG & client-side approach with Astro DB, Actions and server-side islands

Learn how to implement a view counter on your Astro pages using Astro DB, Astro Actions, and server-side islands. We also compare the reliability of incrementing the count using SSG versus a client-side script, highlighting why one method is not feasible.

#astro, #astro db, #astro actions, #server islands

4/1/2025


While searching for tutorials on how to implement a view counter for my blog posts, I came across numerous examples that relied on public API routes (Astro Actions are public by default) and client-side JavaScript to track page views. This got me thinking — why do all these articles opt to increment the view count on the client side, rather than handling it during server-side rendering?

Curious to understand the reasoning behind this, I decided to experiment with both approaches. The answer came pretty quickly, and the results were quite revealing.

Prerequisites

For this guide, we’ll assume you have a working Astro setup. If you don’t, head over to the Astro’s official documentation or use on of the starter templates from Astro. Once you’re ready, let’s dive into adding the view counter!

Setting Up Your Database with Astro DB

Astro DB is a simple, integrated database solution within Astro that lets you manage data directly within your project. To track page views, we’ll create a table that stores the page slug (for identification) and the view count.

  1. Install the Astro DB Integration

First, we need to install the Astro DB integration to enable database functionality in our project. You can do this easily using the built-in astro add command:

npx astro add db

This will install the necessary packages and set up a basic configuration file at db/config.ts.

  1. Define the PageViews Table

Now that Astro DB is installed, let’s define the table to store page views. The table will have two columns:

  • id: a unique identifier for each page (we’ll use the page’s slug).
  • count: the number of views, initialized to 1.
import { column, defineDb, defineTable } from "astro:db";

// Define the `PageViews` table
const PageViews = defineTable({
    columns: {
        id: column.text({ unique: true, primaryKey: true }), // Unique page identifier
        count: column.number({ default: 1 }), // Default count starts at 1
    },
});

// Export the database configuration
export default defineDb({
    tables: {
        PageViews, // Adding the PageViews table to the database
    },
});

This configuration will create the table in your database, and you can use it to store and update the view count for your pages.

Create Astro Actions to Retrieve and Increment the View Count

Next, we’ll define two server-side actions to interact with the database:

  1. Retrieve View Count: Fetches the current count for a given page.
  2. Increase View Count: Increments the view count for the page.

Create a new file in src/actions/index.ts:

import { defineAction, ActionError } from 'astro:actions';
import { PageViews, db, eq, sql } from "astro:db";
import { z } from 'astro:schema';

export const server = {
    pageViews: {
        // Action to fetch the view count for a given page ID
        get: defineAction({
            input: z.string(),
            handler: async (id) => {
                try {
                    return await db
                        .select()
                        .from(PageViews)
                        .where(eq(PageViews.id, id));
                } catch (e) {
                    console.error(e);
                    throw new ActionError({
                        code: "BAD_REQUEST",
                        message: `Error getting view count for page with id "${id}"`,
                    });
                }
            },
        }),

        // Action to increment the view count for a given page ID
        increase: defineAction({
            input: z.string(),
            handler: async (id) => {
                try {
                    return await db
                        .insert(PageViews)
                        .values({ id })
                        .onConflictDoUpdate({
                            target: PageViews.id,
                            set: { count: sql`count + 1` },
                        })
                        .returning();
                } catch (e) {
                    console.error(e);
                    throw new ActionError({
                        code: "BAD_REQUEST",
                        message: `Error increasing view count for page with id "${id}"`,
                    });
                }
            }
        })
    }
};

Explanation of the code:

  • defineAction: This function is used to define custom server-side actions (like fetching or updating data). It takes an input validation schema (e.g., z.string()) and a handler function that performs the action’s logic.

  • PageViews.get action: This action retrieves the page view count by querying the PageViews table. It filters the data by the provided id and returns the result. Errors are handled with ActionError to provide a custom error message.

  • PageViews.increase action: This action increments the view count. It either inserts a new record or updates an existing one by incrementing the count field. If a conflict (duplicate ID) occurs, it updates the count using onConflictDoUpdate and the sql count + 1 syntax.

  • z.string(): This validates that the input id is a string, ensuring type safety when passing the page identifier to the actions.

  • ActionError: This is used to handle errors in a structured way. If something goes wrong with the action (e.g., database issues), it throws a consistent error with a custom code and message.

Create Astro Components to Display and Increase the View Count

Now, let’s create two Astro components to display and update the view count.

Display the View Count

Create an Astro component to display the view count, which will fetch data from the server.

---
export const prerender = false;

import { actions } from "astro:actions";

interface Props {
    id: string;
}

const { id } = Astro.props;

const { data } = await Astro.callAction(actions.pageViews.get, id);

const count = data?.[0]?.count ?? 0;
---
<p class="inline-block">{count.toLocaleString()} views</p>

Explanation of the code:

  • prerender = false: Disables prerendering so the view count is rendered server side each time the page is requested.

  • Astro.callAction: Calls the server-side pageViews.get action to fetch the view count for the specified page ID.

  • count.toLocaleString(): Formats the view count with locale-specific formatting (e.g., adding commas to large numbers).

To just display the current view count, import the ViewCounter component that we just created. Here’s an example of how to implement it within f. ex. your overview page:

Usage

<ViewCounter server:defer id={post.id}>
   <div slot="fallback" class="inline-block h-[1lh] w-[8ch] animate-pulse rounded-lg bg-neutral-800" />
</ViewCounter>

Explanation of the code:

  • server:defer: The server:defer directive ensures the action to fetch the view count is executed server-side.

  • <div class="inline-block h-[1lh] w-[8ch] animate-pulse rounded-lg bg-neutral-800"></div>: A skeleton loader styled with Tailwind CSS to show an animated placeholder while the view count is being fetched.

Increment and Display the View Count using vanilla js

Let’s create an Astro component under src/components/ViewCounterWithIncrease.astro that will increase and then display the updated view count.

---
interface Props {
    id: string;
}

const {id} = Astro.props;
---
<div id="view-counter-increase" class="inline-block" data-post-id={id}>
    <div id="view-counter-increase__skeleton" class="h-[1lh] w-[8ch] animate-pulse rounded-lg bg-neutral-800"/>
    <p id="view-counter-increase__content" class="empty:hidden"/>
</div>

<script>
    import {actions} from 'astro:actions';

    const initViewCounter = () => {
        const viewCounterIncreaseElement = document.getElementById('view-counter-increase');
        const viewCounterIncreaseSkeletonElement = document.getElementById('view-counter-increase__skeleton');
        const viewCounterIncreaseContentElement = document.getElementById('view-counter-increase__content');

        if (
            viewCounterIncreaseElement === null
            || viewCounterIncreaseSkeletonElement === null
            || viewCounterIncreaseContentElement === null
        ) {
            return;
        }

        const id = viewCounterIncreaseElement.getAttribute('data-post-id');

        if (id === null || id === undefined) {
            viewCounterIncreaseElement.classList.add("hidden");
        }

        const fetchViewCount = async () => {
            const {data, error} = await actions.pageViews.increase(id);

            if (error) {
                throw error;
            }

            const viewCount = data?.[0]?.count ?? 0;

            viewCounterIncreaseSkeletonElement.classList.add("hidden");

            viewCounterIncreaseContentElement.innerHTML = `${viewCount.toLocaleString()} views`;
        }

        fetchViewCount().catch(() => {
            viewCounterIncreaseElement.classList.add("hidden");
        });
    }

    initViewCounter();
</script>

Explanation of the code:

  • Astro.props: The component receives a prop called id, which represents the post identifier. This value is passed from the parent component and is used to fetch and display the view count for a specific post.

  • data-post-id: The id value is set as a data-post-id attribute on the div element, which is then used to associate the view counter with a particular post when interacting with the server-side action.

  • initViewCounter: A JavaScript function that is immediately invoked to set up the view counter logic. It checks for the necessary HTML elements (container, skeleton, and content) and fetches the view count based on the data-post-id.

  • fetchViewCount: An asynchronous function that fetches the view count from a server-side action (actions.pageViews.increase(id)). If successful, it updates the view counter content and hides the skeleton loader. If an error occurs, it hides the entire component.

  • actions.pageViews.increase: A server-side action that increments the view count for the specific post identified by id. The view count is fetched from the server and displayed in the component.

  • Skeleton Loader: A temporary placeholder (view-counter-increase__skeleton) that is shown while the view count is being fetched. It uses Tailwind CSS classes like animate-pulse to create a pulsing animation, indicating a loading state.

  • Error Handling: If any error occurs during the fetching process (e.g., network issue, missing data), the component is hidden by adding the hidden class to the main container, ensuring no invalid content is shown.

  • DOM Manipulation: The script dynamically updates the DOM by hiding the skeleton loader and displaying the actual view count. This is done by adding/removing CSS classes and updating the inner HTML of the content element.

Usage

<ViewCounterWithIncrease id={post.id} />

Increment and Display the View Count using React

Let’s create a React component under src/components/ViewCounter.tsx that will increase and then display the updated view count.

import {actions} from "astro:actions";
import {useCallback, useState, useEffect} from "react";

const useViewCount = (id: string) => {
    const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState<null | unknown>(null);
    const [viewCount, setViewCount] = useState<null | number>(null);

    const fetchData = useCallback(async () => {
		const { data, error } = await actions.pageViews.increase(id);

		setIsLoading(false);

		if (error) {
			throw new Error("Unable to run `pageViews.increase` action");
		}

		if (data === undefined || data[0] === undefined) {
			throw new Error(
				"Returned data of `pageViews.increase` action is unusable",
			);
		}

		setViewCount(data[0].count);
	}, [id]);

	useEffect(() => {
		fetchData().catch((error) => {
			console.error(error);
			setError(error);
		});
	}, [fetchData]);

    return {
        isLoading,
        error,
        viewCount
    }
}

export const ViewCounter = ({id}: {id: string}) => {
    const {
        isLoading,
        error,
        viewCount
    } = useViewCount(id)

    if (error) {
        return;
    }

    return (
        isLoading || viewCount === null ? (
            <div className="inline-block h-[1lh] w-[8ch] animate-pulse rounded-lg bg-neutral-800" />
        ) : (
            <p className="inline-block">{viewCount.toLocaleString()} views</p>
        )
    )
}

Explanation of the code:

  • useState & useEffect: These React hooks are used to manage the loading state, error state, and the view count.

  • actions.pageViews.increase: Calls the server-side action to increment the view count when the component is mounted.

  • Skeleton Loader: Displays a loading skeleton while fetching the view count.

Usage

<ViewCounter client:idle id={post.id} />

Explanation of the code:

  • client:idle: Ensures that the ViewCounter component is only initialized once the page is idle and ready for client-side rendering. This improves performance.

  • id={post.id}: The unique identifier (e.g., slug) to retrieve the correct view count for the page.

Conclusion

Now that you’ve implemented the view counter using Astro DB, Astro Actions, and server-side islands, you have a reliable solution for tracking page views in your Astro project. By using Astro’s built-in tools, we can efficiently manage and display the view count while keeping things simple and organized.

Furthermore, as we’ve explored, it becomes clear why most tutorials prefer the client-side approach.

Happy coding!