Как добавить мгновенный поиск в приложение Next.js (полное руководство)

Alex Chibilyaev

Alex Chibilyaev

5/3/2026

#nextjs#руководство#поиск#разработчик#react
Как добавить мгновенный поиск в приложение Next.js (полное руководство)

Поиск — одна из тех функций, которая кажется простой, пока вы её не начнёте реализовывать. Наивная реализация (фильтрация массива, запросы к базе данных с LIKE) работает нормально при 100 элементах. При 10 000 элементах — или когда пользователи быстро печатают и ожидают мгновенных результатов — она немедленно даёт сбой.

Это руководство охватывает добавление мгновенного поиска производственного качества в приложение Next.js с использованием AACSearch (хостируемый поиск на базе AACSearch). Полная настройка занимает около 30 минут.

Что мы создаём

  • Индекс поиска для вашего контента (товары, статьи, пользователи — любые структурированные данные)
  • Серверный маршрут для безопасного проксирования поисковых запросов
  • Клиентский UI поиска с мгновенными результатами, навигацией с клавиатуры и допуском опечаток
  • Отслеживание аналитики (запросы без результатов, клики по результатам)

Требования

  • Next.js 14+ с App Router
  • TypeScript
  • Аккаунт AACSearch (бесплатный тариф достаточен)

Шаг 1: Создание индекса поиска

В дашборде AACSearch создайте новый индекс. Для блога или сайта документации:

{
	"name": "articles",
	"fields": [
		{ "name": "id", "type": "string" },
		{ "name": "title", "type": "string" },
		{ "name": "content", "type": "string" },
		{ "name": "excerpt", "type": "string" },
		{ "name": "category", "type": "string", "facet": true },
		{ "name": "tags", "type": "string[]", "facet": true },
		{ "name": "published_at", "type": "int64" }
	],
	"default_sorting_field": "published_at"
}

Для каталога продуктов интернет-магазина:

{
	"name": "products",
	"fields": [
		{ "name": "id", "type": "string" },
		{ "name": "title", "type": "string" },
		{ "name": "description", "type": "string" },
		{ "name": "price", "type": "float" },
		{ "name": "category", "type": "string", "facet": true },
		{ "name": "in_stock", "type": "bool", "facet": true }
	],
	"default_sorting_field": "price"
}

Шаг 2: Индексация контента

Установите клиент AACSearch для Node.js:

npm install @AACSearch/client
# или
pnpm add @AACSearch/client

Создайте скрипт индексации в scripts/index-content.ts:

import { AACSearchClient } from "@AACSearch/client";

const client = new AACSearchClient({
	apiUrl: process.env.AACSEARCH_API_URL!,
	apiKey: process.env.AACSEARCH_INGEST_KEY!, // ключ записи, только на сервере
});

async function indexArticles() {
	// Замените на ваш реальный источник данных
	const articles = await fetchAllArticles();

	const documents = articles.map((article) => ({
		id: article.slug,
		title: article.title,
		content: article.content,
		excerpt: article.excerpt,
		category: article.category,
		tags: article.tags,
		published_at: Math.floor(new Date(article.publishedAt).getTime() / 1000),
	}));

	await client.documents("articles").importBatch(documents, {
		action: "upsert", // создать или обновить
	});

	console.log(`Проиндексировано ${documents.length} статей`);
}

indexArticles().catch(console.error);

Запустите его:

npx tsx scripts/index-content.ts

В продакшене запускайте переиндексацию через вебхук при изменении контента (например, при событиях публикации в CMS).

Шаг 3: Создание серверного маршрута поиска

Никогда не выставляйте ключ API поиска напрямую в браузер. Создайте Route Handler Next.js, который проксирует поисковые запросы:

// app/api/search/route.ts
import { NextRequest, NextResponse } from "next/server";

const AACSEARCH_API_URL = process.env.AACSEARCH_API_URL!;
const AACSEARCH_SEARCH_KEY = process.env.AACSEARCH_SEARCH_KEY!; // ключ только для чтения

export async function GET(request: NextRequest) {
	const { searchParams } = new URL(request.url);
	const query = searchParams.get("q");

	if (!query || query.trim().length === 0) {
		return NextResponse.json({ hits: [], found: 0 });
	}

	const response = await fetch(
		`${AACSEARCH_API_URL}/api/search?` +
			new URLSearchParams({
				q: query,
				query_by: "title,excerpt,content",
				per_page: "10",
				highlight_full_fields: "title,excerpt",
			}),
		{
			headers: {
				Authorization: `Bearer ${AACSEARCH_SEARCH_KEY}`,
			},
		},
	);

	if (!response.ok) {
		return NextResponse.json({ error: "search_failed" }, { status: 502 });
	}

	const data = await response.json();
	return NextResponse.json(data);
}

Добавьте переменные окружения в .env.local:

AACSEARCH_API_URL=https://api.AACSearch.com
AACSEARCH_SEARCH_KEY=ss_search_your_read_only_key
AACSEARCH_INGEST_KEY=ss_connector_your_write_key

Шаг 4: Создание UI-компонента поиска

Создайте клиентский компонент для интерфейса поиска:

// components/search/SearchModal.tsx
"use client";

import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";

interface SearchHit {
  document: {
    id: string;
    title: string;
    excerpt: string;
    category: string;
  };
  highlights: Array<{
    field: string;
    snippet: string;
  }>;
}

interface SearchResults {
  hits: SearchHit[];
  found: number;
}

export function SearchModal({
  onClose,
}: {
  onClose: () => void;
}) {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<SearchResults | null>(null);
  const [loading, setLoading] = useState(false);
  const [selectedIndex, setSelectedIndex] = useState(0);
  const inputRef = useRef<HTMLInputElement>(null);
  const router = useRouter();

  // Фокус на инпут при монтировании
  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  // Поиск с debounce
  useEffect(() => {
    if (!query.trim()) {
      setResults(null);
      return;
    }

    const timer = setTimeout(async () => {
      setLoading(true);
      try {
        const res = await fetch(
          `/api/search?q=${encodeURIComponent(query)}`
        );
        const data = await res.json();
        setResults(data);
        setSelectedIndex(0);
      } finally {
        setLoading(false);
      }
    }, 150); // 150ms debounce — достаточно быстро для ощущения мгновенного поиска

    return () => clearTimeout(timer);
  }, [query]);

  // Навигация с клавиатуры
  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      const hits = results?.hits ?? [];

      if (e.key === "Escape") {
        onClose();
      } else if (e.key === "ArrowDown") {
        e.preventDefault();
        setSelectedIndex((i) => Math.min(i + 1, hits.length - 1));
      } else if (e.key === "ArrowUp") {
        e.preventDefault();
        setSelectedIndex((i) => Math.max(i - 1, 0));
      } else if (e.key === "Enter" && hits[selectedIndex]) {
        router.push(`/articles/${hits[selectedIndex].document.id}`);
        onClose();
      }
    },
    [results, selectedIndex, router, onClose]
  );

  const getHighlight = (hit: SearchHit, field: string) => {
    const h = hit.highlights.find((h) => h.field === field);
    return h?.snippet ?? hit.document[field as keyof typeof hit.document];
  };

  return (
    <div
      className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh] bg-black/50"
      onClick={onClose}
    >
      <div
        className="w-full max-w-xl bg-background rounded-xl shadow-2xl border border-border overflow-hidden"
        onClick={(e) => e.stopPropagation()}
        onKeyDown={handleKeyDown}
      >
        {/* Поле ввода поиска */}
        <div className="flex items-center px-4 border-b border-border">
          <svg
            className="w-4 h-4 text-muted-foreground shrink-0"
            fill="none"
            viewBox="0 0 24 24"
            stroke="currentColor"
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              strokeWidth={2}
              d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
            />
          </svg>
          <input
            ref={inputRef}
            type="text"
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="Поиск..."
            className="flex-1 px-3 py-4 bg-transparent outline-none text-sm"
          />
          {loading && (
            <div className="w-4 h-4 border-2 border-muted-foreground border-t-transparent rounded-full animate-spin" />
          )}
        </div>

        {/* Результаты */}
        {results && results.hits.length > 0 && (
          <ul className="max-h-80 overflow-y-auto py-2">
            {results.hits.map((hit, i) => (
              <li key={hit.document.id}>
                <button
                  className={`w-full text-left px-4 py-3 hover:bg-muted transition-colors ${
                    i === selectedIndex ? "bg-muted" : ""
                  }`}
                  onClick={() => {
                    router.push(`/articles/${hit.document.id}`);
                    onClose();
                  }}
                >
                  <p
                    className="text-sm font-medium"
                    dangerouslySetInnerHTML={{
                      __html: getHighlight(hit, "title"),
                    }}
                  />
                  <p
                    className="text-xs text-muted-foreground mt-0.5 line-clamp-1"
                    dangerouslySetInnerHTML={{
                      __html: getHighlight(hit, "excerpt"),
                    }}
                  />
                </button>
              </li>
            ))}
          </ul>
        )}

        {/* Нет результатов */}
        {results && results.hits.length === 0 && query.trim() && (
          <div className="px-4 py-8 text-center text-sm text-muted-foreground">
            Нет результатов для &ldquo;{query}&rdquo;
          </div>
        )}

        {/* Нижняя панель */}
        <div className="px-4 py-2 border-t border-border flex gap-3 text-xs text-muted-foreground">
          <span>↑↓ навигация</span>
          <span>↵ открыть</span>
          <span>esc закрыть</span>
        </div>
      </div>
    </div>
  );
}

Шаг 5: Подключение триггера

Добавьте кнопку поиска в ваш layout, которая открывает модальное окно:

// components/search/SearchButton.tsx
"use client";

import { useState, useEffect } from "react";
import { SearchModal } from "./SearchModal";

export function SearchButton() {
  const [open, setOpen] = useState(false);

  // Горячая клавиша Cmd+K / Ctrl+K
  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        setOpen(true);
      }
    };
    document.addEventListener("keydown", handler);
    return () => document.removeEventListener("keydown", handler);
  }, []);

  return (
    <>
      <button
        onClick={() => setOpen(true)}
        className="flex items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground border border-border rounded-lg hover:bg-muted transition-colors"
      >
        <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
        </svg>
        Поиск
        <kbd className="ml-auto text-xs opacity-60">K</kbd>
      </button>

      {open && <SearchModal onClose={() => setOpen(false)} />}
    </>
  );
}

Добавьте его в ваш layout:

// app/layout.tsx
import { SearchButton } from "@/components/search/SearchButton";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ru">
      <body>
        <header className="flex items-center justify-between px-6 py-4 border-b">
          <a href="/">Моё приложение</a>
          <SearchButton />
        </header>
        <main>{children}</main>
      </body>
    </html>
  );
}

Шаг 6: Поддержание актуальности индекса

Для часто изменяемого контента запускайте переиндексацию при обновлении контента. С вебхуком:

// app/api/webhooks/content/route.ts
import { NextRequest, NextResponse } from "next/server";
import { AACSearchClient } from "@AACSearch/client";

const client = new AACSearchClient({
	apiUrl: process.env.AACSEARCH_API_URL!,
	apiKey: process.env.AACSEARCH_INGEST_KEY!,
});

export async function POST(request: NextRequest) {
	const { event, article } = await request.json();

	if (event === "article.published" || event === "article.updated") {
		await client.documents("articles").upsert({
			id: article.slug,
			title: article.title,
			content: article.content,
			excerpt: article.excerpt,
			category: article.category,
			tags: article.tags,
			published_at: Math.floor(Date.now() / 1000),
		});
	}

	if (event === "article.deleted") {
		await client.documents("articles").delete(article.slug);
	}

	return NextResponse.json({ ok: true });
}

Что вы получаете

После этой настройки:

  • Ответы на поиск < 50 мс независимо от размера индекса
  • Допуск опечаток — "javascrpt" находит результаты по JavaScript
  • Совпадение по префиксу — результаты появляются после 2–3 символов
  • Навигация с клавиатуры — полная доступность без мыши
  • Триггер Cmd+K — стандартное сочетание клавиш для опытных пользователей
  • Нулевая нагрузка на сервер — поиск выполняется против API AACSearch, не вашего сервера Next.js

Компонент модального окна поиска составляет ~120 строк чистого React — без дополнительных зависимостей UI-библиотек.

Чеклист для продакшена

  • [ ] Ключ поиска только для чтения (никогда не ключ инgestии)
  • [ ] Route Handler валидирует и санитизирует параметр q
  • [ ] dangerouslySetInnerHTML рендерит только подсвеченные фрагменты AACSearch (не пользовательский ввод)
  • [ ] Debounce ≥ 150 мс (предотвращает rate limiting)
  • [ ] Запросы без результатов логируются в аналитике
  • [ ] Индекс обновляется при событиях публикации/обновления/удаления контента
  • [ ] Переменные окружения настроены в продакшене (не только в .env.local)