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
Useful links

