Files
shiftcraft/.claude/skills/pocketbase-best-practices/references/sdk-usage.md
2026-04-17 23:26:01 +00:00

28 KiB

SDK Usage

Impact: HIGH

JavaScript SDK initialization, auth store patterns, error handling, request cancellation, and safe parameter binding.


1. Use Appropriate Auth Store for Your Platform

Impact: HIGH (Proper auth persistence across sessions and page reloads)

The auth store persists authentication state. Choose the right store type based on your platform: LocalAuthStore for browsers, AsyncAuthStore for React Native, or custom stores for specific needs.

Incorrect (wrong store for platform):

// React Native: LocalAuthStore doesn't work correctly
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
// Auth state lost on app restart!

// Deno server: LocalStorage shared between all clients
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
// All clients share the same auth state!

// Server-side: Reusing single client for multiple users
const pb = new PocketBase('http://127.0.0.1:8090');
// User A logs in...
// User B's request uses User A's auth!

Correct (platform-appropriate stores):

// Browser (default LocalAuthStore - works automatically)
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
// Automatically persists to localStorage and syncs between tabs

// React Native (AsyncAuthStore)
import PocketBase, { AsyncAuthStore } from 'pocketbase';
import AsyncStorage from '@react-native-async-storage/async-storage';

const store = new AsyncAuthStore({
  save: async (serialized) => {
    await AsyncStorage.setItem('pb_auth', serialized);
  },
  initial: AsyncStorage.getItem('pb_auth'),
  clear: async () => {
    await AsyncStorage.removeItem('pb_auth');
  }
});

const pb = new PocketBase('http://127.0.0.1:8090', store);

// Server-side / SSR (create client per request)
import PocketBase from 'pocketbase';

export function createServerClient(cookieHeader) {
  const pb = new PocketBase('http://127.0.0.1:8090');
  pb.authStore.loadFromCookie(cookieHeader || '');
  return pb;
}

// Deno/Cloudflare Workers (memory-only store)
import PocketBase, { BaseAuthStore } from 'pocketbase';

class MemoryAuthStore extends BaseAuthStore {
  // Token only persists for request duration
  // Each request must include auth via cookie/header
}

const pb = new PocketBase('http://127.0.0.1:8090', new MemoryAuthStore());

Custom auth store example:

import PocketBase, { BaseAuthStore } from 'pocketbase';

class SecureAuthStore extends BaseAuthStore {
  constructor() {
    super();
    // Load initial state from secure storage
    const data = secureStorage.get('pb_auth');
    if (data) {
      const { token, record } = JSON.parse(data);
      this.save(token, record);
    }
  }

  save(token, record) {
    super.save(token, record);
    // Persist to secure storage
    secureStorage.set('pb_auth', JSON.stringify({ token, record }));
  }

  clear() {
    super.clear();
    secureStorage.remove('pb_auth');
  }
}

const pb = new PocketBase('http://127.0.0.1:8090', new SecureAuthStore());

Auth store methods:

// Available on all auth stores
pb.authStore.token;        // Current token
pb.authStore.record;       // Current auth record
pb.authStore.isValid;      // Token exists and not expired
pb.authStore.isSuperuser;  // Is superuser token

pb.authStore.save(token, record);  // Save auth state
pb.authStore.clear();              // Clear auth state

// Listen for changes
const unsubscribe = pb.authStore.onChange((token, record) => {
  console.log('Auth changed:', record?.email);
}, true);  // true = fire immediately

// Cookie helpers (for SSR)
pb.authStore.loadFromCookie(cookieString);
pb.authStore.exportToCookie({ httpOnly: false, secure: true });

Reference: PocketBase Authentication

2. Understand and Control Auto-Cancellation

Impact: MEDIUM (Prevents race conditions, improves UX for search/typeahead)

The SDK automatically cancels duplicate pending requests. This prevents race conditions but requires understanding for proper use in concurrent scenarios.

Incorrect (confused by auto-cancellation):

// These requests will interfere with each other!
async function loadDashboard() {
  // Only the last one executes, others cancelled
  const posts = pb.collection('posts').getList(1, 20);
  const users = pb.collection('posts').getList(1, 10);  // Different params but same path
  const comments = pb.collection('posts').getList(1, 5);

  // posts and users are cancelled, only comments executes
  return Promise.all([posts, users, comments]);  // First two fail!
}

// Realtime combined with polling causes cancellation
pb.collection('posts').subscribe('*', callback);
setInterval(() => {
  pb.collection('posts').getList();  // May cancel realtime!
}, 5000);

Correct (controlling auto-cancellation):

// Disable auto-cancellation for parallel requests
async function loadDashboard() {
  const [posts, users, recent] = await Promise.all([
    pb.collection('posts').getList(1, 20, { requestKey: null }),
    pb.collection('users').getList(1, 10, { requestKey: null }),
    pb.collection('posts').getList(1, 5, { requestKey: 'recent' })
  ]);
  // All requests complete independently
  return { posts, users, recent };
}

// Use unique request keys for different purposes
async function searchPosts(query) {
  return pb.collection('posts').getList(1, 20, {
    filter: pb.filter('title ~ {:q}', { q: query }),
    requestKey: 'post-search'  // Cancels previous searches only
  });
}

async function loadPostDetails(postId) {
  return pb.collection('posts').getOne(postId, {
    requestKey: `post-${postId}`  // Unique per post
  });
}

// Typeahead search - auto-cancellation is helpful here
async function typeaheadSearch(query) {
  // Previous search automatically cancelled when user types more
  return pb.collection('products').getList(1, 10, {
    filter: pb.filter('name ~ {:q}', { q: query })
    // No requestKey = uses default (path-based), previous cancelled
  });
}

// Globally disable auto-cancellation (use carefully)
pb.autoCancellation(false);

// Now all requests are independent
await Promise.all([
  pb.collection('posts').getList(1, 20),
  pb.collection('posts').getList(1, 10),
  pb.collection('posts').getList(1, 5)
]);

// Re-enable
pb.autoCancellation(true);

Manual cancellation:

// Cancel all pending requests
pb.cancelAllRequests();

// Cancel specific request by key
pb.cancelRequest('post-search');

// Example: Cancel on component unmount
function PostList() {
  useEffect(() => {
    loadPosts();

    return () => {
      // Cleanup: cancel pending requests
      pb.cancelRequest('post-list');
    };
  }, []);

  async function loadPosts() {
    const result = await pb.collection('posts').getList(1, 20, {
      requestKey: 'post-list'
    });
    setPosts(result.items);
  }
}

// Handle cancellation in catch
async function fetchWithCancellation() {
  try {
    return await pb.collection('posts').getList();
  } catch (error) {
    if (error.isAbort) {
      // Request was cancelled - this is expected
      console.log('Request cancelled');
      return null;
    }
    throw error;
  }
}

When to use each approach:

Scenario Approach
Search/typeahead Default (let it cancel)
Parallel data loading requestKey: null
Grouped requests Custom requestKey
Component cleanup cancelRequest(key)
Testing/debugging autoCancellation(false)
OAuth2 flow cancel cancelRequest(requestKey) — properly rejects the authWithOAuth2() Promise (JS SDK v0.26.8+)

Note (JS SDK v0.26.8): Calling pb.cancelRequest(requestKey) while authWithOAuth2() is waiting now properly rejects the returned Promise. In earlier versions the manual cancellation did not account for the waiting realtime subscription, so the Promise could hang indefinitely.

Reference: PocketBase Auto-Cancellation

3. Handle SDK Errors Properly

Impact: HIGH (Graceful error recovery, better UX, easier debugging)

All SDK methods return Promises that may reject with ClientResponseError. Proper error handling improves user experience and simplifies debugging.

Incorrect (ignoring or poorly handling errors):

// No error handling
const posts = await pb.collection('posts').getList();

// Generic catch that loses information
try {
  await pb.collection('posts').create({ title: '' });
} catch (e) {
  alert('Something went wrong');  // No useful info
}

// Not checking specific error types
try {
  await pb.collection('posts').getOne('nonexistent');
} catch (e) {
  console.log(e.message);  // Missing status, response details
}

Correct (comprehensive error handling):

import PocketBase, { ClientResponseError } from 'pocketbase';

const pb = new PocketBase('http://127.0.0.1:8090');

// Basic error handling with ClientResponseError
async function createPost(data) {
  try {
    return await pb.collection('posts').create(data);
  } catch (error) {
    if (error instanceof ClientResponseError) {
      console.log('Status:', error.status);
      console.log('Response:', error.response);
      console.log('URL:', error.url);
      console.log('Is abort:', error.isAbort);

      // Handle specific status codes
      switch (error.status) {
        case 400:
          // Validation error - extract user-friendly messages only
          // IMPORTANT: Don't expose raw error.response.data to clients
          // as it may leak internal field names and validation rules
          const fieldErrors = {};
          if (error.response?.data) {
            for (const [field, details] of Object.entries(error.response.data)) {
              fieldErrors[field] = details.message;
            }
          }
          return { error: 'validation', fields: fieldErrors };
        case 401:
          // Unauthorized - need to login
          return { error: 'unauthorized' };
        case 403:
          // Forbidden - no permission
          return { error: 'forbidden' };
        case 404:
          // Not found
          return { error: 'not_found' };
        default:
          return { error: 'server_error' };
      }
    }
    throw error;  // Re-throw non-PocketBase errors
  }
}

// Handle validation errors with field details
async function updateProfile(userId, data) {
  try {
    return await pb.collection('users').update(userId, data);
  } catch (error) {
    if (error.status === 400 && error.response?.data) {
      // Extract field-specific errors
      const fieldErrors = {};
      for (const [field, details] of Object.entries(error.response.data)) {
        fieldErrors[field] = details.message;
      }
      return { success: false, errors: fieldErrors };
      // { errors: { email: "Invalid email format", name: "Required field" } }
    }
    throw error;
  }
}

// Handle request cancellation
async function searchWithCancel(query) {
  try {
    return await pb.collection('posts').getList(1, 20, {
      filter: pb.filter('title ~ {:query}', { query })
    });
  } catch (error) {
    if (error.isAbort) {
      // Request was cancelled (e.g., user typed again)
      console.log('Search cancelled');
      return null;
    }
    throw error;
  }
}

// Wrapper function for consistent error handling
async function pbRequest(fn) {
  try {
    return { data: await fn(), error: null };
  } catch (error) {
    if (error instanceof ClientResponseError) {
      return {
        data: null,
        error: {
          status: error.status,
          message: error.response?.message || 'Request failed',
          data: error.response?.data || null
        }
      };
    }
    return {
      data: null,
      error: { status: 0, message: error.message, data: null }
    };
  }
}

// Usage
const { data, error } = await pbRequest(() =>
  pb.collection('posts').getList(1, 20)
);

if (error) {
  console.log('Failed:', error.message);
} else {
  console.log('Posts:', data.items);
}

ClientResponseError structure:

interface ClientResponseError {
  url: string;           // The request URL
  status: number;        // HTTP status code (0 if network error)
  response: {            // API response body
    code: number;
    message: string;
    data: { [field: string]: { code: string; message: string } };
  };
  isAbort: boolean;      // True if request was cancelled
  cause: Error | null;   // Original error (added in JS SDK v0.26.1)
}

Reference: PocketBase Error Handling

4. Use Field Modifiers for Incremental Updates

Impact: HIGH (Atomic updates, prevents race conditions, cleaner code)

PocketBase supports + and - modifiers for incrementing numbers, appending/removing relation IDs, and managing file arrays without replacing the entire value.

Incorrect (read-modify-write pattern):

// Race condition: two users adding tags simultaneously
async function addTag(postId, newTagId) {
  const post = await pb.collection('posts').getOne(postId);
  const currentTags = post.tags || [];

  // Another user might have added a tag in between!
  await pb.collection('posts').update(postId, {
    tags: [...currentTags, newTagId]  // Might overwrite the other user's tag
  });
}

// Inefficient for incrementing counters
async function incrementViews(postId) {
  const post = await pb.collection('posts').getOne(postId);
  await pb.collection('posts').update(postId, {
    views: post.views + 1  // Extra read, race condition
  });
}

Correct (using field modifiers):

// Atomic relation append with + modifier
async function addTag(postId, newTagId) {
  await pb.collection('posts').update(postId, {
    'tags+': newTagId  // Appends to existing tags atomically
  });
}

// Append multiple relations
async function addTags(postId, tagIds) {
  await pb.collection('posts').update(postId, {
    'tags+': tagIds  // Appends array of IDs
  });
}

// Prepend relations (+ prefix)
async function prependTag(postId, tagId) {
  await pb.collection('posts').update(postId, {
    '+tags': tagId  // Prepends to start of array
  });
}

// Remove relations with - modifier
async function removeTag(postId, tagId) {
  await pb.collection('posts').update(postId, {
    'tags-': tagId  // Removes specific tag
  });
}

// Remove multiple relations
async function removeTags(postId, tagIds) {
  await pb.collection('posts').update(postId, {
    'tags-': tagIds  // Removes all specified tags
  });
}

// Atomic number increment
async function incrementViews(postId) {
  await pb.collection('posts').update(postId, {
    'views+': 1  // Atomic increment, no race condition
  });
}

// Atomic number decrement
async function decrementStock(productId, quantity) {
  await pb.collection('products').update(productId, {
    'stock-': quantity  // Atomic decrement
  });
}

// File append (for multi-file fields)
async function addImage(albumId, newImage) {
  await pb.collection('albums').update(albumId, {
    'images+': newImage  // Appends new file to existing
  });
}

// File removal
async function removeImage(albumId, filename) {
  await pb.collection('albums').update(albumId, {
    'images-': filename  // Removes specific file by name
  });
}

// Combined modifiers in single update
async function updatePost(postId, data) {
  await pb.collection('posts').update(postId, {
    title: data.title,        // Replace field
    'views+': 1,              // Increment number
    'tags+': data.newTagId,   // Append relation
    'tags-': data.oldTagId,   // Remove relation
    'images+': data.newImage  // Append file
  });
}

Modifier reference:

Modifier Field Types Description
field+ or +field relation, file Append/prepend to array
field- relation, file Remove from array
field+ number Increment by value
field- number Decrement by value

Benefits:

  • Atomic: No read-modify-write race conditions
  • Efficient: Single request, no extra read needed
  • Clean: Expresses intent clearly

Note: Modifiers only work with update(), not create().

Reference: PocketBase Relations

5. Use Safe Parameter Binding in Filters

Impact: CRITICAL (Prevents injection attacks, handles special characters correctly)

Always use pb.filter() with parameter binding when constructing filters with user input. String concatenation is vulnerable to injection attacks.

Incorrect (string concatenation - DANGEROUS):

// SQL/filter injection vulnerability!
async function searchPosts(userInput) {
  // User input: `test" || id != "` breaks out of string
  const posts = await pb.collection('posts').getList(1, 20, {
    filter: `title ~ "${userInput}"`  // VULNERABLE!
  });
  return posts;
}

// Even with escaping, easy to get wrong
async function searchByEmail(email) {
  const escaped = email.replace(/"/g, '\\"');  // Incomplete escaping
  const users = await pb.collection('users').getList(1, 1, {
    filter: `email = "${escaped}"`  // Still potentially vulnerable
  });
  return users;
}

// Template literals are just as dangerous
const filter = `status = "${status}" && author = "${authorId}"`;

Correct (using pb.filter with parameters):

// Safe parameter binding
async function searchPosts(userInput) {
  const posts = await pb.collection('posts').getList(1, 20, {
    filter: pb.filter('title ~ {:search}', { search: userInput })
  });
  return posts;
}

// Multiple parameters
async function filterPosts(status, authorId, minViews) {
  const posts = await pb.collection('posts').getList(1, 20, {
    filter: pb.filter(
      'status = {:status} && author = {:author} && views >= {:views}',
      { status, author: authorId, views: minViews }
    )
  });
  return posts;
}

// Reusing parameters
async function searchBothFields(query) {
  const results = await pb.collection('posts').getList(1, 20, {
    filter: pb.filter(
      'title ~ {:q} || content ~ {:q}',
      { q: query }  // Same parameter used twice
    )
  });
  return results;
}

// Different parameter types
async function complexFilter(options) {
  const filter = pb.filter(
    'created > {:date} && active = {:active} && category = {:cat}',
    {
      date: new Date('2024-01-01'),  // Date objects handled correctly
      active: true,                    // Booleans
      cat: options.category            // Strings auto-escaped
    }
  );

  return pb.collection('posts').getList(1, 20, { filter });
}

// Null handling
async function filterWithOptional(category) {
  // Only include filter if value provided
  const filter = category
    ? pb.filter('category = {:cat}', { cat: category })
    : '';

  return pb.collection('posts').getList(1, 20, { filter });
}

// Building dynamic filters
async function dynamicSearch(filters) {
  const conditions = [];
  const params = {};

  if (filters.title) {
    conditions.push('title ~ {:title}');
    params.title = filters.title;
  }

  if (filters.author) {
    conditions.push('author = {:author}');
    params.author = filters.author;
  }

  if (filters.minDate) {
    conditions.push('created >= {:minDate}');
    params.minDate = filters.minDate;
  }

  const filter = conditions.length > 0
    ? pb.filter(conditions.join(' && '), params)
    : '';

  return pb.collection('posts').getList(1, 20, { filter });
}

Supported parameter types:

Type Example Notes
string 'hello' Auto-escaped, quotes handled
number 123, 45.67 No quotes added
boolean true, false Converted correctly
Date new Date() Formatted for PocketBase
null null For null comparisons
other {...} JSON.stringify() applied

Server-side is especially critical:

// Server-side code (Node.js, Deno, etc.) MUST use binding
// because malicious users control the input directly

export async function searchHandler(req) {
  const userQuery = req.query.q;  // Untrusted input!

  // ALWAYS use pb.filter() on server
  const results = await pb.collection('posts').getList(1, 20, {
    filter: pb.filter('title ~ {:q}', { q: userQuery })
  });

  return results;
}

Reference: PocketBase Filters

6. Initialize PocketBase Client Correctly

Impact: HIGH (Proper setup enables auth persistence, SSR support, and optimal performance)

Client initialization should consider the environment (browser, Node.js, SSR), auth store persistence, and any required polyfills.

Incorrect (environment-agnostic initialization):

// Missing polyfills in Node.js
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');

// Node.js: EventSource not defined error on realtime
pb.collection('posts').subscribe('*', callback);  // Fails!

// Missing base URL
const pb = new PocketBase();  // Uses '/' - likely wrong

Correct (environment-aware initialization):

// Browser setup (no polyfills needed)
// IMPORTANT: Use HTTPS in production (http is only for local development)
import PocketBase from 'pocketbase';

const pb = new PocketBase('http://127.0.0.1:8090');  // Use https:// in production

// Node.js setup (requires polyfills for realtime)
import PocketBase from 'pocketbase';
import { EventSource } from 'eventsource';

// Global polyfill for realtime
global.EventSource = EventSource;

const pb = new PocketBase('http://127.0.0.1:8090');

// React Native setup (async auth store)
import PocketBase, { AsyncAuthStore } from 'pocketbase';
import AsyncStorage from '@react-native-async-storage/async-storage';
import EventSource from 'react-native-sse';

global.EventSource = EventSource;

const store = new AsyncAuthStore({
  save: async (serialized) => AsyncStorage.setItem('pb_auth', serialized),
  initial: AsyncStorage.getItem('pb_auth'),
});

const pb = new PocketBase('http://127.0.0.1:8090', store);

SSR initialization (per-request client):

// SvelteKit example
// src/hooks.server.js
import PocketBase from 'pocketbase';

export async function handle({ event, resolve }) {
  // Create fresh client for each request
  event.locals.pb = new PocketBase('http://127.0.0.1:8090');

  // Load auth from request cookie
  event.locals.pb.authStore.loadFromCookie(
    event.request.headers.get('cookie') || ''
  );

  // Validate token
  if (event.locals.pb.authStore.isValid) {
    try {
      await event.locals.pb.collection('users').authRefresh();
    } catch {
      event.locals.pb.authStore.clear();
    }
  }

  const response = await resolve(event);

  // Send updated auth cookie with secure options
  response.headers.append(
    'set-cookie',
    event.locals.pb.authStore.exportToCookie({
      httpOnly: true,   // Prevent XSS access to auth token
      secure: true,     // HTTPS only
      sameSite: 'Lax',  // CSRF protection
    })
  );

  return response;
}

TypeScript with typed collections:

import PocketBase, { RecordService } from 'pocketbase';

// Define your record types
interface User {
  id: string;
  email: string;
  name: string;
  avatar?: string;
}

interface Post {
  id: string;
  title: string;
  content: string;
  author: string;
  published: boolean;
}

// Create typed client interface
interface TypedPocketBase extends PocketBase {
  collection(idOrName: string): RecordService;
  collection(idOrName: 'users'): RecordService<User>;
  collection(idOrName: 'posts'): RecordService<Post>;
}

const pb = new PocketBase('http://127.0.0.1:8090') as TypedPocketBase;

// Now methods are typed
const post = await pb.collection('posts').getOne('abc');  // Returns Post
const users = await pb.collection('users').getList();      // Returns ListResult<User>

Reference: PocketBase JS SDK

7. Use Send Hooks for Request Customization

Impact: MEDIUM (Custom headers, logging, response transformation)

The SDK provides beforeSend and afterSend hooks for intercepting and modifying requests and responses globally.

Incorrect (repeating logic in every request):

// Adding headers to every request manually
const posts = await pb.collection('posts').getList(1, 20, {
  headers: { 'X-Custom-Header': 'value' }
});

const users = await pb.collection('users').getList(1, 20, {
  headers: { 'X-Custom-Header': 'value' }  // Repeated!
});

// Logging each request manually
console.log('Fetching posts...');
const posts = await pb.collection('posts').getList();
console.log('Done');

Correct (using send hooks):

import PocketBase from 'pocketbase';

const pb = new PocketBase('http://127.0.0.1:8090');

// beforeSend - modify requests before they're sent
pb.beforeSend = function(url, options) {
  // Add custom headers to all requests
  options.headers = Object.assign({}, options.headers, {
    'X-Custom-Header': 'value',
    'X-Request-ID': crypto.randomUUID()
  });

  // Log outgoing requests
  console.log(`[${options.method}] ${url}`);

  // Must return { url, options }
  return { url, options };
};

// afterSend - process responses
pb.afterSend = function(response, data) {
  // Log response status
  console.log(`Response: ${response.status}`);

  // Transform or extend response data
  if (data && typeof data === 'object') {
    data._fetchedAt = new Date().toISOString();
  }

  // Return the (possibly modified) data
  return data;
};

// All requests now automatically have headers and logging
const posts = await pb.collection('posts').getList();
const users = await pb.collection('users').getList();

Practical examples:

// Request timing / performance monitoring
let requestStart;
pb.beforeSend = function(url, options) {
  requestStart = performance.now();
  return { url, options };
};

pb.afterSend = function(response, data) {
  const duration = performance.now() - requestStart;
  console.log(`${response.url}: ${duration.toFixed(2)}ms`);

  // Send to analytics
  trackApiPerformance(response.url, duration);

  return data;
};

// Add auth token from different source
pb.beforeSend = function(url, options) {
  const externalToken = getTokenFromExternalAuth();
  if (externalToken) {
    options.headers = Object.assign({}, options.headers, {
      'X-External-Auth': externalToken
    });
  }
  return { url, options };
};

// Handle specific response codes globally
pb.afterSend = function(response, data) {
  if (response.status === 401) {
    // Token expired - trigger re-auth
    handleAuthExpired();
  }

  if (response.status === 503) {
    // Service unavailable - show maintenance message
    showMaintenanceMode();
  }

  return data;
};

// Retry failed requests (simplified example)
const originalSend = pb.send.bind(pb);
pb.send = async function(path, options) {
  try {
    return await originalSend(path, options);
  } catch (error) {
    if (error.status === 429) {  // Rate limited
      await sleep(1000);
      return originalSend(path, options);  // Retry once
    }
    throw error;
  }
};

// Add request correlation for debugging
let requestId = 0;
pb.beforeSend = function(url, options) {
  requestId++;
  const correlationId = `req-${Date.now()}-${requestId}`;

  options.headers = Object.assign({}, options.headers, {
    'X-Correlation-ID': correlationId
  });

  console.log(`[${correlationId}] Starting: ${url}`);
  return { url, options };
};

pb.afterSend = function(response, data) {
  console.log(`Complete: ${response.status}`);
  return data;
};

Hook signatures:

// beforeSend
beforeSend?: (
  url: string,
  options: SendOptions
) => { url: string; options: SendOptions } | Promise<{ url: string; options: SendOptions }>;

// afterSend
afterSend?: (
  response: Response,
  data: any
) => any | Promise<any>;

Use cases:

  • Add custom headers (API keys, correlation IDs)
  • Request/response logging
  • Performance monitoring
  • Global error handling
  • Response transformation
  • Authentication middleware

Reference: PocketBase Send Hooks