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.
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.
The issue with incrementing the count on server side rendering
Astro automatically adds a <link rel="preload" as="fetch" href="/_server-islands/ViewCounter" crossorigin="anonymous" />
tag in the page’s header when using a server-side island component.
While preloading resources is generally beneficial for performance, it can cause a specific issue with certain browsers (e.g., Safari on iOS): the count increments by +2 instead of the expected +1. This happens because the server-side island is fetched twice, which leads to an inaccurate count.
As per the Astro documentation, there is currently no way to modify this behavior.
Given this limitation, it seems that the most straightforward solution is to handle the count increment on the client-side instead.
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.
- 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
.
- 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:
- Retrieve View Count: Fetches the current count for a given page.
- 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 thePageViews
table. It filters the data by the provided id and returns the result. Errors are handled withActionError
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 thecount
field. If a conflict (duplicate ID) occurs, it updates the count usingonConflictDoUpdate
and thesql
count + 1
syntax. -
z.string()
: This validates that the inputid
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 calledid
, 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
: Theid
value is set as adata-post-id
attribute on thediv
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 thedata-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 byid
. 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 likeanimate-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 theViewCounter
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!