TL;DR: You don’t need invasive analytics to build engaging UIs. By combining Astro’s React Islands, Nanostores for cross-component state, and browser localStorage, we can engineer a robust, privacy-first engagement system that tracks likes and views without compromising user data or adding external dependencies.
The modern web is bogged down by bloated analytics scripts and third-party trackers. As engineers, we often default to reaching for a heavy SaaS solution when a lightweight, localized approach would suffice. If your goal is simply to provide users with a feedback mechanism—like a “clap” or “like” button—while tracking basic engagement, you can build a highly performant, local-first system entirely on the client.
In this post, we’ll architect a minimalist Engagement Dock. We will use Astro 6 for our foundation, React Islands for isolated interactivity, Nanostores for predictable state management, and the localStorage API as our data layer.
The Architecture of Local-First State
The core philosophy here is zero external dependencies for core functionality. The user’s interaction data belongs on their machine.
Our architecture relies on three pillars:
- Astro Pages: The static shell that delivers the initial HTML.
- Nanostores: A tiny, framework-agnostic state manager. It acts as the single source of truth during the user’s session.
- Local Storage: The persistent persistence layer, ensuring state survives page reloads.
By isolating state management outside the React component tree using Nanostores, we decouple our logic from the UI framework, leading to a cleaner, more testable design.
Implementing the State Layer (Nanostores)
First, we define our state. We need a store to hold the interaction counts for specific paths.
// src/store/engagement.ts
import { map } from 'nanostores';
// Define the shape of our engagement data
export interface EngagementData {
likes: number;
views: number;
hasLiked: boolean;
}
// Create a map store where keys are URL paths
export const engagementStore = map<Record<string, EngagementData>>({});
/**
* Initializes or retrieves the engagement data for a specific path from local storage.
*/
export function initEngagement(path: string) {
if (typeof window === 'undefined') return;
const storageKey = `hub-metrics-${path}`;
const storedData = localStorage.getItem(storageKey);
if (storedData) {
try {
const parsed = JSON.parse(storedData) as EngagementData;
engagementStore.setKey(path, parsed);
} catch (e) {
console.error('Failed to parse engagement data', e);
// Fallback to default state
engagementStore.setKey(path, { likes: 0, views: 1, hasLiked: false });
}
} else {
// Initial visit
const initialState = { likes: 0, views: 1, hasLiked: false };
engagementStore.setKey(path, initialState);
localStorage.setItem(storageKey, JSON.stringify(initialState));
}
}
This code snippet demonstrates a resilient approach. We handle the window === 'undefined' check to ensure SSR compatibility during Astro’s build process. We also gracefully handle corrupted local storage data.
The React Interaction Island
With our state decoupled, the React component becomes a pure projection of that state, handling only user events and UI updates.
// src/components/EngagementDock.tsx
import React, { useEffect } from 'react';
import { useStore } from '@nanostores/react';
import { engagementStore, initEngagement } from '../store/engagement.ts';
interface Props {
path: string;
}
export const EngagementDock: React.FC<Props> = ({ path }) => {
// Subscribe to the specific slice of the store
const store = useStore(engagementStore);
const data = store[path] || { likes: 0, views: 0, hasLiked: false };
useEffect(() => {
// Initialize data on mount
initEngagement(path);
}, [path]);
const handleLike = () => {
if (data.hasLiked) return; // Prevent spam
const newState = {
...data,
likes: data.likes + 1,
hasLiked: true,
};
// Update Nanostore
engagementStore.setKey(path, newState);
// Persist to Local Storage
const storageKey = `hub-metrics-${path}`;
localStorage.setItem(storageKey, JSON.stringify(newState));
};
return (
<div className="fixed bottom-4 right-4 flex gap-4 p-3 bg-gray-900 rounded-full shadow-lg border border-gray-800">
<button
onClick={handleLike}
disabled={data.hasLiked}
className={`flex items-center gap-2 px-3 py-1 rounded-full transition-colors ${
data.hasLiked ? 'text-green-400 bg-gray-800' : 'text-gray-300 hover:text-white hover:bg-gray-700'
}`}
aria-label="Like this post"
>
<svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"></path>
</svg>
<span className="font-mono text-sm">{data.likes}</span>
</button>
<div className="flex items-center gap-2 px-3 py-1 text-gray-400">
<svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<span className="font-mono text-sm">{data.views}</span>
</div>
</div>
);
};
Notice the crucial detail here: <svg aria-hidden="true"...>. As per strict accessibility standards, we must ensure purely decorative inline SVGs are hidden from screen readers. We rely on the button’s aria-label to provide context.
Integration in Astro
Finally, we drop our interactive island into the static Astro layout.
---
// src/pages/blog/[slug].astro
import { EngagementDock } from '../../components/EngagementDock';
const { slug } = Astro.params;
const path = Astro.url.pathname;
---
<Layout title="Blog Post">
<main class="prose dark:prose-invert max-w-3xl mx-auto">
<!-- Article Content -->
<slot />
</main>
{/* Client-side hydration: load when idle */}
<EngagementDock client:idle path={path} />
</Layout>
By using the client:idle directive, Astro defers loading the React runtime and our component logic until the main thread is free. This ensures our core content renders immediately, preserving a stellar Lighthouse score while still delivering an interactive experience.
Conclusion
Building local-first features isn’t just about saving money on analytics platforms; it’s a fundamental shift towards respecting user privacy and optimizing for extreme performance. By treating localStorage as a primary data store and managing state intelligently with Nanostores, you can construct resilient, lightning-fast UIs that empower users without tracking them.
Discussions
Be the first to share your thoughts or ask a question.