In Part 1, we explored building a custom website crawler using Azure Functions to index content for Azure AI Search. Now, in Part 2, we'll dive into creating a front-end implementation for search components using Next.js within a typical Sitecore XM Cloud setup. This guide will demonstrate how to leverage the power of Azure AI Search in your Sitecore-powered website's user interface.
FE Client Architecture
The solution consists of three main components:
1. Azure AI Search service - handles indexing and search operations
2. Next.js API routes - middleware layer between frontend and Azure Search
3. React components - user interface for search functionality
graph LR
A[Next.js Frontend] --> |API Requests| B[Next.js API Routes]
B -->|Search Queries| C[Azure AI Search]
C -->|Search Results| B
B -->|Formatted Data| A
D[Sitecore XM Cloud] -->|Content| E[Azure Function Crawler]
E -->|Indexed Content| C
style A fill:#FFB3BA
style B fill:#BAFFC9
style C fill:#BAE1FF
style D fill:#FFFFBA
style E fill:#FFD8B8
Implementation
1. Setting Up the Search Client
First, let's create a utility function to initialize the Azure Search client using the @azure/search-documents SDK:
// utils/searchClient.ts import { SearchClient, AzureKeyCredential } from "@azure/search-documents"; const endpoint = process.env.AZURE_SEARCH_ENDPOINT || ""; const apiKey = process.env.AZURE_SEARCH_API_KEY || ""; const indexName = process.env.AZURE_SEARCH_INDEX_NAME || ""; // Initialize the search client export const searchClient = new SearchClient( endpoint, indexName, new AzureKeyCredential(apiKey) );
2. Creating the API Route
Next, we'll create an API route to handle search requests.
Using a Next.js API route to handle search requests offers several benefits:
- Security: API credentials are kept server-side, preventing exposure to clients.
- Simplification: Complex search logic can be abstracted away from the frontend.
- Caching: Implement server-side caching for improved performance.
- Customization: Easily modify search requests and responses as needed.
- Rate Limiting: Implement rate limiting to prevent abuse of the search API.
- Logging: Add server-side logging for better monitoring and debugging.
- Error Handling: Centralized error handling for search-related issues.
// app/api/search/route.ts import { NextResponse } from "next/server"; import { searchClient } from "@/utils/searchClient"; // Define the structure of a search result interface SearchResult { id: string; title: string; description: string; url: string; // Additional fields can be added based on the index schema } export async function POST(request: Request) { try { // Parse the incoming request body const body = await request.json(); const { searchQuery, filters, facets } = body; // Set up search options const searchOptions = { select: ["id", "title", "description", "url"], // Fields to return filter: filters, // Apply any filters facets: facets, // Include facets for refinement top: 10, // Limit results to 10 skip: 0, // Start from the first result includeTotalCount: true, // Get total count of results }; // Execute the search using Azure Search const searchResults = await searchClient.search(searchQuery, searchOptions); // Transform the results into a more usable format const results = []; for await (const result of searchResults.results) { results.push(result.document); } // Return the search results, total count, and facets return NextResponse.json({ results, count: searchResults.count, facets: searchResults.facets, }); } catch (error) { // Log the error and return a 500 status code console.error("Search error:", error); return NextResponse.json( { error: "Failed to perform search" }, { status: 500 } ); }
}
3. Building Example Search Component
Now, let's create a React component for the search interface:
// components/Search/SearchBox.tsx "use client"; import { useState, useCallback } from "react"; import debounce from "lodash/debounce"; interface SearchBoxProps { onSearch: (query: string) => void; placeholder?: string; } export default function SearchBox({ onSearch, placeholder = "Search...", }: SearchBoxProps) { const [searchTerm, setSearchTerm] = useState(""); // Debounce the search to prevent too many API calls const debouncedSearch = useCallback( debounce((query: string) => { onSearch(query); }, 300), [] ); const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => { const query = event.target.value; setSearchTerm(query); debouncedSearch(query); }; return ( <div className="relative"> <input type="text" value={searchTerm} onChange={handleSearch} placeholder={placeholder} className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" /> {/* Add search icon */} <svg className="absolute right-3 top-2.5 h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> </svg> </div> );
}
4. Creating the Search Results Component
// components/Search/SearchResults.tsx "use client"; import { useState } from "react"; interface SearchResultProps { results: Array<{ id: string; title: string; description: string; url: string; }>; } export default function SearchResults({ results }: SearchResultProps) { return ( <div className="space-y-4"> {results.map((result) => ( <div key={result.id} className="p-4 border border-gray-200 rounded-lg hover:shadow-md transition-shadow" > <h3 className="text-lg font-semibold text-blue-600"> <a href={result.url}>{result.title}</a> </h3> <p className="mt-2 text-gray-600">{result.description}</p> </div> ))} {results.length === 0 && ( <div className="text-center text-gray-500">No results found</div> )} </div> );
}
5. Putting It All Together
Finally, let's create a page that combines all these components:
// app/search/page.tsx "use client"; import { useState } from "react"; import SearchBox from "@/components/Search/SearchBox"; import SearchResults from "@/components/Search/SearchResults"; export default function SearchPage() { const [searchResults, setSearchResults] = useState([]); const [isLoading, setIsLoading] = useState(false); const handleSearch = async (query: string) => { if (!query) { setSearchResults([]); return; } setIsLoading(true); try { const response = await fetch("/api/search", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ searchQuery: query, }), }); const data = await response.json(); setSearchResults(data.results); } catch (error) { console.error("Search failed:", error); } finally { setIsLoading(false); } }; return ( <div className="max-w-4xl mx-auto px-4 py-8"> <h1 className="text-3xl font-bold mb-8">Search</h1> <SearchBox onSearch={handleSearch} /> {isLoading ? ( <div className="mt-8 text-center">Loading...</div> ) : ( <div className="mt-8"> <SearchResults results={searchResults} /> </div> )} </div> );
}
Performance Optimization
1. Server-Side Pagination: Implement pagination to handle large result sets efficiently.
2. Caching: Consider implementing caching for frequent searches.
3. Loading States: Show appropriate loading states during API calls.
Conclusion
This implementation provides a solid foundation for a modern search experience in a Sitecore CM Cloud environment using Next.js and Azure AI Search. The code is maintainable, type-safe, and follows best practices for both frontend and API development.
The solution can be extended further by adding features such as:
- Advanced filtering
- Faceted search
- Search suggestions
- Analytics integration
- Custom ranking profiles