← Back to articles
6 min read

Local-First Engagement: Building a Privacy-Focused Dock

How to architect a minimalist, zero-tracker engagement dock using React, Nanostores, and local storage in Astro.

Local-First Engagement: Building a Privacy-Focused Dock
In this post

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:

  1. Astro Pages: The static shell that delivers the initial HTML.
  2. Nanostores: A tiny, framework-agnostic state manager. It acts as the single source of truth during the user’s session.
  3. 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.

120