Storage Adapters

Learn how adapters decouple your consent and DSR data from any specific storage backend

What Are Adapters?

In v3, every hook that reads or writes persistent data accepts an optional adapter prop. An adapter is a small object that implements the StorageAdapter interface — three methods that map to load, save, and remove operations.

Because the adapter sits between the toolkit and your storage layer, you can swap backends without touching any of your component code. A team prototype might use localStorageAdapter; a production deployment can point the same components at a REST API adapter — zero component changes required.

Why This Matters for NDPA Compliance

The NDPA 2023 requires organisations to demonstrate that consent records are accurate, timestamped, and auditable. Adapters make it straightforward to persist consent to a durable, queryable backend (e.g. a database) while keeping your UI layer clean and testable.

The StorageAdapter Interface

Every adapter must implement the following TypeScript interface, exported from @tantainnovative/ndpr-toolkit:

export interface StorageAdapter<T = unknown> {
  /**
   * Load the stored value. Returns null if nothing has been saved yet.
   */
  load(): T | null | Promise<T | null>;

  /**
   * Persist the value to the underlying storage.
   */
  save(data: T): void | Promise<void>;

  /**
   * Remove the stored value entirely.
   */
  remove(): void | Promise<void>;
}

Methods may be synchronous or async — the toolkit handles both. Even the built-in localStorage adapter is synchronous under the hood, while the API adapter returns Promises for all operations.

Built-in Adapters

The toolkit ships six ready-to-use adapters. Import them directly from the package:

import {
  localStorageAdapter,
  sessionStorageAdapter,
  cookieAdapter,
  apiAdapter,
  memoryAdapter,
  composeAdapters,
} from '@tantainnovative/ndpr-toolkit';
AdapterStorageBest ForSSR Safe
localStorageAdapterWindow.localStoragePrototypes, SPAsYes (no-ops on server)
sessionStorageAdapterWindow.sessionStorageTab-scoped sessionsYes (no-ops on server)
cookieAdapterBrowser cookiesCross-tab, SSR accessYes
apiAdapterREST API endpointProduction, multi-deviceYes
memoryAdapterIn-memory MapTesting, SSR fallbackYes
composeAdaptersMultiple (ordered)Local cache + API syncYes

Using Adapters with Hooks

Pass the adapter as a configuration option to any hook that supports it. Here is a minimal example using useConsent:

import { useConsent, localStorageAdapter } from '@tantainnovative/ndpr-toolkit';

export function ConsentBanner() {
  const { hasConsent, updateConsent } = useConsent({
    options: consentOptions,
    adapter: localStorageAdapter('my-app-consents'),
  });

  return (
    <div>
      <p>Analytics enabled: {hasConsent('analytics') ? 'Yes' : 'No'}</p>
      <button onClick={() => updateConsent('analytics', true)}>
        Accept Analytics
      </button>
    </div>
  );
}

Swap to a production API adapter by changing a single line — your component stays the same:

import { useConsent, apiAdapter } from '@tantainnovative/ndpr-toolkit';

export function ConsentBanner() {
  const { hasConsent, updateConsent } = useConsent({
    options: consentOptions,
    adapter: apiAdapter('/api/consent'),
  });
  // ...same component body
}

Creating a Custom Adapter

Any object that satisfies the StorageAdapter interface works. The following example stores consent records in a Supabase table:

import type { StorageAdapter } from '@tantainnovative/ndpr-toolkit';
import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);

// The key is baked into the adapter factory, not passed per-call
export function supabaseConsentAdapter(userId: string): StorageAdapter {
  return {
    async load() {
      const { data } = await supabase
        .from('consent_records')
        .select('value')
        .eq('user_id', userId)
        .single();
      return data?.value ?? null;
    },

    async save(data) {
      await supabase
        .from('consent_records')
        .upsert({ user_id: userId, value: data, updated_at: new Date().toISOString() });
    },

    async remove() {
      await supabase.from('consent_records').delete().eq('user_id', userId);
    },
  };
}

Then use it anywhere you would pass a built-in adapter:

const { hasConsent } = useConsent({
  options: consentOptions,
  adapter: supabaseConsentAdapter(currentUserId),
});

The composeAdapters Pattern

composeAdapters chains multiple adapters so that reads come from the first available source and writes fan out to all layers. This is the recommended production pattern: serve reads from localStorage (fast, offline-capable) and sync writes to your API (durable, auditable).

import {
  useConsent,
  composeAdapters,
  localStorageAdapter,
  apiAdapter,
} from '@tantainnovative/ndpr-toolkit';

// Reads: localStorage first (returns immediately if found, skips API)
// Writes: propagated to BOTH localStorage AND the API
const composedAdapter = composeAdapters(
  localStorageAdapter('consent-cache'),
  apiAdapter('/api/consent'),
);

export function ConsentBanner() {
  const { hasConsent, updateConsent } = useConsent({
    options: consentOptions,
    adapter: composedAdapter,
  });
  // ...
}

Order Matters

Adapters in the array are tried left-to-right for reads. The first non-null result wins. All adapters receive every write. Put your fastest / most local adapter first.

Three-layer Setup (local + API + audit log)

const auditAdapter: StorageAdapter = {
  load() { return null; },  // audit log is write-only
  async save(data) {
    await fetch('/api/audit', {
      method: 'POST',
      body: JSON.stringify({ action: 'SAVE', data, ts: Date.now() }),
    });
  },
  async remove() {
    await fetch('/api/audit', {
      method: 'POST',
      body: JSON.stringify({ action: 'REMOVE', ts: Date.now() }),
    });
  },
};

const adapter = composeAdapters(
  localStorageAdapter('consent-cache'),  // 1. fast local read
  apiAdapter('/api/consent'),            // 2. durable server storage
  auditAdapter,                          // 3. immutable audit trail
);

Testing with memoryAdapter

Use memoryAdapter in unit tests so there are no browser globals or network calls:

import { renderHook, act } from '@testing-library/react';
import { useConsent, memoryAdapter } from '@tantainnovative/ndpr-toolkit';

test('consent is persisted via adapter', async () => {
  const { result } = renderHook(() =>
    useConsent({ adapter: memoryAdapter() })
  );

  await act(async () => {
    await result.current.updateConsent('analytics', true);
  });

  expect(result.current.hasConsented('analytics')).toBe(true);
});

memoryAdapter() returns a fresh instance each call, so tests are fully isolated.