Web Development

Local-First Engagement: Building a Privacy-Focused Dock

Web Development

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.

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.

Table of Contents


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

// 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>({});

/**
 * 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

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 (
    <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>

      <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>
      );
};

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

const { slug } = Astro.params;
const path = Astro.url.pathname;
---

<Layout title="Blog Post">
  <main class="prose prose-theme max-w-3xl mx-auto">
    <!-- Article Content -->
    <slot />
  </main>

  {/* Client-side hydration: load when idle */}
  

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.