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';| Adapter | Storage | Best For | SSR Safe |
|---|---|---|---|
| localStorageAdapter | Window.localStorage | Prototypes, SPAs | Yes (no-ops on server) |
| sessionStorageAdapter | Window.sessionStorage | Tab-scoped sessions | Yes (no-ops on server) |
| cookieAdapter | Browser cookies | Cross-tab, SSR access | Yes |
| apiAdapter | REST API endpoint | Production, multi-device | Yes |
| memoryAdapter | In-memory Map | Testing, SSR fallback | Yes |
| composeAdapters | Multiple (ordered) | Local cache + API sync | Yes |
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.