Claude
Skills
Sign in
Back

search

Included with Lifetime
$97 forever

Setup Meilisearch full-text search with Redis caching, rate limiting, input sanitization, and API key management. Use this skill when the user says "setup search", "add search", "setup meilisearch", "full-text search", or "search integration".

Backend & APIs

What this skill does


# Meilisearch Search Integration

Production-ready full-text search with Meilisearch, featuring Redis caching, rate limiting, input sanitization, and secure API key management.

## Features

- **Dual-Environment Support**: Local development and production with different API keys
- **Input Sanitization**: Zod-based query validation to prevent injection attacks
- **Redis Caching**: 60-second TTL for search results
- **Rate Limiting**: 20 requests/minute per IP address
- **Index Management**: Type-safe index creation and document indexing
- **API Key Rotation**: Automated key rotation for production security
- **CORS Support**: Configurable allowed origins

## Environment Variables

### Local Development

```env
# Meilisearch (local via Docker)
MEILISEARCH_HOST=http://localhost:7700
MEILISEARCH_API_KEY=devMasterKey123
```

### Production

```env
# Meilisearch (production)
MEILISEARCH_HOST=https://your-meilisearch-instance.com
MEILISEARCH_API_KEY=your_master_key_here
MEILISEARCH_SEARCH_KEY=your_search_only_key_here

# Required for env detection
VERCEL_ENV=production
```

## Docker Setup

Meilisearch is included in the Docker skill. If not already set up, add to `docker-compose.yml`:

```yaml
services:
  meilisearch:
    image: getmeili/meilisearch:v1.10
    ports:
      - "7700:7700"
    environment:
      MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-devMasterKey123}
      MEILI_NO_ANALYTICS: "true"
      MEILI_ENV: development
    volumes:
      - meilisearch_data:/meili_data
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:7700/health"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

volumes:
  meilisearch_data:
```

## Prerequisites

Add `scripts/` to tsconfig.json `exclude` to avoid path resolution errors with seed scripts:

```json
{
  "exclude": ["node_modules", "scripts"]
}
```

Also add `MEILISEARCH_SEARCH_KEY` and `VERCEL_ENV` to your `src/env.ts` server section:

```typescript
MEILISEARCH_SEARCH_KEY: z.string().optional(),
VERCEL_ENV: z.string().optional(),
```

## Installation

```bash
bun add meilisearch zod lucide-react
```

## File Structure

```
src/
├── lib/
│   └── search.ts                # Meilisearch client and utilities
├── hooks/
│   ├── use-search.ts            # React hook for search with debouncing
│   ├── use-hotkey.ts            # Global keyboard shortcut hook
│   └── use-search-params-sync.ts # URL query param synchronization
├── components/
│   ├── search-box.tsx           # Basic search input component
│   └── search-modal.tsx         # Command palette search modal (⌘K)
└── app/
    ├── api/
    │   └── search/
    │       └── route.ts         # Search API endpoint
    └── search/
        └── page.tsx             # Search results page with URL params
```

## Implementation

### File: `src/lib/search.ts`

```typescript
import { MeiliSearch } from "meilisearch";
import { z } from "zod";

// Use process.env directly to avoid @/env blocking route handlers during env validation
const isProduction = process.env.VERCEL_ENV === "production";
const host = process.env.MEILISEARCH_HOST ?? "http://localhost:7700";

// === CLIENT INITIALIZATION ===

// Admin client with full permissions (server-side only)
export const searchClient = new MeiliSearch({
  host,
  apiKey: isProduction
    ? (process.env.MEILISEARCH_API_KEY ?? "")
    : "devMasterKey123",
});

// Search-only client for frontend (limited permissions)
export const searchOnlyClient = new MeiliSearch({
  host,
  apiKey: process.env.MEILISEARCH_SEARCH_KEY ?? "devMasterKey123",
});

// === INPUT SANITIZATION ===

const searchQuerySchema = z.object({
  query: z
    .string()
    .max(500)
    .transform((q) => q.replace(/[<>"'\\]/g, "").trim()),
  limit: z.number().int().min(1).max(100).default(20),
  offset: z.number().int().min(0).default(0),
  filter: z.string().max(1000).optional(),
  sort: z.array(z.string()).max(5).optional(),
});

export type SearchQuery = z.infer<typeof searchQuerySchema>;

export function sanitizeSearchQuery(input: unknown): SearchQuery {
  return searchQuerySchema.parse(input);
}

// === CACHED SEARCH ===
// Note: Redis caching is optional. When redis is not available, searches go directly to Meilisearch.
// To enable caching, install ioredis and import your redis client here.

export async function cachedSearch(index: string, query: SearchQuery) {
  // Without Redis, fall back to direct search
  return directSearch(index, query);
}

// === DIRECT SEARCH (NO CACHE) ===

export async function directSearch(index: string, query: SearchQuery) {
  return searchClient.index(index).search(query.query, {
    limit: query.limit,
    offset: query.offset,
    filter: query.filter,
    sort: query.sort,
  });
}

// === INDEX MANAGEMENT ===

export async function createProductIndex() {
  const index = searchClient.index("products");

  await index.updateSettings({
    searchableAttributes: ["name", "description", "category"],
    filterableAttributes: ["category", "price", "inStock"],
    sortableAttributes: ["price", "createdAt", "name"],
    rankingRules: [
      "words",
      "typo",
      "proximity",
      "attribute",
      "sort",
      "exactness",
    ],
  });

  return index;
}

// Generic index configuration helper
export async function configureIndex(
  indexName: string,
  settings: {
    searchableAttributes?: string[];
    filterableAttributes?: string[];
    sortableAttributes?: string[];
    rankingRules?: string[];
    stopWords?: string[];
    synonyms?: Record<string, string[]>;
    distinctAttribute?: string;
  }
) {
  const index = searchClient.index(indexName);
  const task = await index.updateSettings(settings);
  return { index, task };
}

// === DOCUMENT OPERATIONS ===

type BaseDocument = {
  id: string;
  [key: string]: unknown;
};

export async function indexDocuments<T extends BaseDocument>(
  indexName: string,
  documents: T[],
  primaryKey = "id"
) {
  const index = searchClient.index(indexName);
  return index.addDocuments(documents, { primaryKey });
}

export async function updateDocuments<T extends BaseDocument>(
  indexName: string,
  documents: T[],
  primaryKey = "id"
) {
  const index = searchClient.index(indexName);
  return index.updateDocuments(documents, { primaryKey });
}

export async function deleteDocuments(indexName: string, ids: string[]) {
  const index = searchClient.index(indexName);
  return index.deleteDocuments(ids);
}

export async function deleteAllDocuments(indexName: string) {
  const index = searchClient.index(indexName);
  return index.deleteAllDocuments();
}

// === TASK MANAGEMENT ===

export async function waitForTask(taskUid: number) {
  return searchClient.tasks.waitForTask(taskUid);
}

export async function getTaskStatus(taskUid: number) {
  return searchClient.tasks.getTask(taskUid);
}

// === API KEY MANAGEMENT (Production) ===

export async function createSearchKey(
  indexes: string[],
  expiresInDays = 30,
  description?: string
) {
  if (!isProduction) return null;

  const newKey = await searchClient.createKey({
    description: description ?? `Search key created at ${new Date().toISOString()}`,
    actions: ["search"],
    indexes,
    expiresAt: new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000),
  });

  return newKey;
}

export async function rotateApiKeys() {
  if (!isProduction) return null;

  // Create new search key
  const newKey = await searchClient.createKey({
    description: `Search key rotated at ${new Date().toISOString()}`,
    actions: ["search"],
    indexes: ["*"],
    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
  });

  return newKey;
}

export async function listApiKeys() {
  return searchClient.getKeys();
}

export async function deleteApiKey(keyUidOrKey: string) {
  return searchClient.deleteKey(keyUidOrKey);
}

// === STATS & HEALTH ===

export async function getIndexStats(indexName: string) {
  const index = searchClient.index(indexName);
  return index.getStats();
}

export async f
Files: 1
Size: 35.7 KB
Complexity: 39/100
Category: Backend & APIs

Related in Backend & APIs