Sitecore Headless Search Implementation Guide - ysolution-it/sitecore-wiki GitHub Wiki

Sitecore Headless Search Implementation Guide

This guide provides a complete implementation for creating a search page in a Sitecore headless portal. The solution includes both backend API and frontend React components with full search functionality.

Overview

The search implementation consists of:

  • Backend API: C# controller that handles search requests and integrates with Sitecore's search capabilities
  • Frontend Component: React-based search interface with filtering, pagination, and responsive design
  • Search Features: Full-text search, content type filtering, pagination, and error handling

Backend Implementation

1. Search API Controller

Create a new API controller to handle search requests:

// Controllers/SearchController.cs
using Microsoft.AspNetCore.Mvc;
using Sitecore.AspNet.RenderingEngine.Configuration;
using Sitecore.AspNet.RenderingEngine.Services;
using Sitecore.LayoutService.Client.Exceptions;
using Sitecore.LayoutService.Client.Interfaces;
using System.Threading.Tasks;

[ApiController]
[Route("api/[controller]")]
public class SearchController : ControllerBase
{
    private readonly ILayoutServiceClient _layoutServiceClient;
    private readonly RenderingEngineOptions _renderingEngineOptions;

    public SearchController(
        ILayoutServiceClient layoutServiceClient,
        RenderingEngineOptions renderingEngineOptions)
    {
        _layoutServiceClient = layoutServiceClient;
        _renderingEngineOptions = renderingEngineOptions;
    }

    [HttpGet]
    public async Task<IActionResult> Search(
        [FromQuery] string query,
        [FromQuery] int page = 1,
        [FromQuery] int pageSize = 10,
        [FromQuery] string[] contentTypes = null,
        [FromQuery] string language = "en")
    {
        try
        {
            if (string.IsNullOrWhiteSpace(query))
            {
                return BadRequest("Search query is required");
            }

            // Build search parameters
            var searchParams = new Dictionary<string, object>
            {
                ["query"] = query,
                ["page"] = page,
                ["pageSize"] = pageSize,
                ["language"] = language
            };

            if (contentTypes != null && contentTypes.Length > 0)
            {
                searchParams["contentTypes"] = contentTypes;
            }

            // Call Sitecore search endpoint
            var searchResults = await PerformSearch(searchParams);

            return Ok(searchResults);
        }
        catch (Exception ex)
        {
            return StatusCode(500, new { message = "Search failed", error = ex.Message });
        }
    }

    private async Task<object> PerformSearch(Dictionary<string, object> searchParams)
    {
        // Option 1: Using Sitecore Search (recommended)
        // This assumes you have Sitecore Search configured
        var searchQuery = BuildSitecoreSearchQuery(searchParams);
        
        // Option 2: Using GraphQL
        // var graphQLQuery = BuildGraphQLQuery(searchParams);
        // var result = await _layoutServiceClient.Request(graphQLQuery);
        
        // Option 3: Using Content Search API
        // var contentSearchQuery = BuildContentSearchQuery(searchParams);
        
        // Mock response - replace with actual implementation
        return new
        {
            Results = new[]
            {
                new
                {
                    Id = "item1",
                    Title = "Sample Article",
                    Content = "This is sample content...",
                    Url = "/articles/sample-article",
                    ContentType = "Article",
                    LastModified = DateTime.UtcNow.AddDays(-5)
                }
            },
            TotalCount = 1,
            Page = (int)searchParams["page"],
            PageSize = (int)searchParams["pageSize"],
            TotalPages = 1
        };
    }

    private string BuildSitecoreSearchQuery(Dictionary<string, object> searchParams)
    {
        // Build your Sitecore Search query here
        // This will depend on your specific Sitecore Search configuration
        return $"q={searchParams["query"]}&rows={searchParams["pageSize"]}&start={(((int)searchParams["page"] - 1) * (int)searchParams["pageSize"])}";
    }
}

Backend Controller Explanation

Key Components:

  1. Dependency Injection: Uses ILayoutServiceClient for Sitecore integration
  2. Search Parameters: Accepts query, pagination, content type filters, and language
  3. Flexible Search Methods: Supports multiple Sitecore search approaches
  4. Error Handling: Proper HTTP response codes and error messages
  5. Pagination Support: Built-in pagination with configurable page sizes

Search Integration Options:

  • Sitecore Search: Modern cloud-based search service (recommended)
  • GraphQL: Query Sitecore content via GraphQL endpoints
  • Content Search API: Traditional Sitecore search using Lucene/Solr

Frontend Implementation

2. React Search Component

Create a comprehensive search component with modern UI:

// components/SearchPage.jsx
import React, { useState, useEffect, useCallback } from 'react';
import { Search, Filter, ChevronLeft, ChevronRight, Clock, FileText } from 'lucide-react';

const SearchPage = () => {
  const [searchQuery, setSearchQuery] = useState('');
  const [searchResults, setSearchResults] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [totalResults, setTotalResults] = useState(0);
  const [currentPage, setCurrentPage] = useState(1);
  const [pageSize] = useState(10);
  const [contentTypeFilter, setContentTypeFilter] = useState([]);
  const [error, setError] = useState(null);

  const contentTypes = [
    { value: 'Article', label: 'Articles' },
    { value: 'Page', label: 'Pages' },
    { value: 'News', label: 'News' },
    { value: 'Event', label: 'Events' },
    { value: 'Product', label: 'Products' }
  ];

  const performSearch = useCallback(async (query, page = 1, filters = []) => {
    if (!query.trim()) {
      setSearchResults([]);
      setTotalResults(0);
      return;
    }

    setIsLoading(true);
    setError(null);

    try {
      const params = new URLSearchParams({
        query: query.trim(),
        page: page.toString(),
        pageSize: pageSize.toString()
      });

      if (filters.length > 0) {
        filters.forEach(filter => params.append('contentTypes', filter));
      }

      const response = await fetch(`/api/search?${params}`);
      
      if (!response.ok) {
        throw new Error(`Search failed: ${response.statusText}`);
      }

      const data = await response.json();
      
      setSearchResults(data.Results || []);
      setTotalResults(data.TotalCount || 0);
      setCurrentPage(data.Page || 1);
    } catch (err) {
      setError(err.message);
      setSearchResults([]);
      setTotalResults(0);
    } finally {
      setIsLoading(false);
    }
  }, [pageSize]);

  const handleSearch = (e) => {
    e.preventDefault();
    setCurrentPage(1);
    performSearch(searchQuery, 1, contentTypeFilter);
  };

  const handlePageChange = (newPage) => {
    setCurrentPage(newPage);
    performSearch(searchQuery, newPage, contentTypeFilter);
  };

  const handleFilterChange = (contentType) => {
    const newFilters = contentTypeFilter.includes(contentType)
      ? contentTypeFilter.filter(f => f !== contentType)
      : [...contentTypeFilter, contentType];
    
    setContentTypeFilter(newFilters);
    setCurrentPage(1);
    performSearch(searchQuery, 1, newFilters);
  };

  const totalPages = Math.ceil(totalResults / pageSize);

  return (
    <div className="max-w-6xl mx-auto p-6">
      <div className="mb-8">
        <h1 className="text-3xl font-bold text-gray-900 mb-6">Search Portal</h1>
        
        {/* Search Form */}
        <form onSubmit={handleSearch} className="mb-6">
          <div className="relative">
            <input
              type="text"
              value={searchQuery}
              onChange={(e) => setSearchQuery(e.target.value)}
              placeholder="Search for content..."
              className="w-full pl-12 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
            />
            <Search className="absolute left-4 top-3.5 h-5 w-5 text-gray-400" />
            <button
              type="submit"
              className="absolute right-3 top-2 px-4 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
            >
              Search
            </button>
          </div>
        </form>

        {/* Content Type Filters */}
        <div className="mb-6">
          <div className="flex items-center gap-2 mb-3">
            <Filter className="h-5 w-5 text-gray-600" />
            <span className="font-medium text-gray-700">Filter by content type:</span>
          </div>
          <div className="flex flex-wrap gap-2">
            {contentTypes.map(type => (
              <button
                key={type.value}
                onClick={() => handleFilterChange(type.value)}
                className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
                  contentTypeFilter.includes(type.value)
                    ? 'bg-blue-600 text-white'
                    : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
                }`}
              >
                {type.label}
              </button>
            ))}
          </div>
        </div>
      </div>

      {/* Search Results */}
      {isLoading && (
        <div className="text-center py-12">
          <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
          <p className="text-gray-600 mt-4">Searching...</p>
        </div>
      )}

      {error && (
        <div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
          <p className="text-red-800">Error: {error}</p>
        </div>
      )}

      {!isLoading && !error && searchQuery && (
        <div className="mb-6">
          <p className="text-gray-600">
            Found {totalResults} results for "{searchQuery}"
            {contentTypeFilter.length > 0 && (
              <span className="ml-2">
                (filtered by: {contentTypeFilter.join(', ')})
              </span>
            )}
          </p>
        </div>
      )}

      {/* Results List */}
      {searchResults.length > 0 && (
        <div className="space-y-4">
          {searchResults.map((result, index) => (
            <div key={result.Id || index} className="bg-white border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow">
              <div className="flex items-start justify-between">
                <div className="flex-1">
                  <h3 className="text-xl font-semibold text-blue-600 hover:text-blue-800 mb-2">
                    <a href={result.Url} className="hover:underline">
                      {result.Title}
                    </a>
                  </h3>
                  <p className="text-gray-700 mb-3 line-clamp-2">
                    {result.Content}
                  </p>
                  <div className="flex items-center gap-4 text-sm text-gray-500">
                    <span className="flex items-center gap-1">
                      <FileText className="h-4 w-4" />
                      {result.ContentType}
                    </span>
                    <span className="flex items-center gap-1">
                      <Clock className="h-4 w-4" />
                      {new Date(result.LastModified).toLocaleDateString()}
                    </span>
                  </div>
                </div>
              </div>
            </div>
          ))}
        </div>
      )}

      {/* Pagination */}
      {totalPages > 1 && (
        <div className="flex items-center justify-center gap-2 mt-8">
          <button
            onClick={() => handlePageChange(currentPage - 1)}
            disabled={currentPage === 1}
            className="flex items-center gap-1 px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
          >
            <ChevronLeft className="h-4 w-4" />
            Previous
          </button>
          
          {[...Array(Math.min(5, totalPages))].map((_, i) => {
            const page = i + 1;
            return (
              <button
                key={page}
                onClick={() => handlePageChange(page)}
                className={`px-3 py-2 text-sm font-medium rounded-lg ${
                  currentPage === page
                    ? 'bg-blue-600 text-white'
                    : 'text-gray-500 bg-white border border-gray-300 hover:bg-gray-50'
                }`}
              >
                {page}
              </button>
            );
          })}
          
          <button
            onClick={() => handlePageChange(currentPage + 1)}
            disabled={currentPage === totalPages}
            className="flex items-center gap-1 px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
          >
            Next
            <ChevronRight className="h-4 w-4" />
          </button>
        </div>
      )}

      {/* No Results */}
      {!isLoading && !error && searchQuery && searchResults.length === 0 && (
        <div className="text-center py-12">
          <p className="text-gray-600 text-lg">No results found for "{searchQuery}"</p>
          <p className="text-gray-500 mt-2">Try adjusting your search terms or filters</p>
        </div>
      )}
    </div>
  );
};

export default SearchPage;

Frontend Component Explanation

State Management:

  • searchQuery: Current search term
  • searchResults: Array of search results
  • isLoading: Loading state indicator
  • totalResults: Total number of results for pagination
  • currentPage: Current page number
  • contentTypeFilter: Active content type filters
  • error: Error state for error handling

Key Features:

  1. Search Form:

    • Controlled input with search icon
    • Form submission handling
    • Inline search button
  2. Content Type Filtering:

    • Toggle-style filter buttons
    • Multiple filter selection
    • Automatic search re-execution on filter change
  3. Results Display:

    • Card-based layout for each result
    • Displays title, content preview, type, and date
    • Clickable titles linking to content
  4. Pagination:

    • Previous/Next navigation
    • Page number buttons
    • Responsive pagination display
  5. Loading States:

    • Spinner animation during search
    • Error message display
    • Empty state handling

Implementation Steps

Step 1: Backend Setup

  1. Add the SearchController to your Sitecore headless application
  2. Configure Dependencies in your Startup.cs or Program.cs
  3. Set up Search Integration:
    • For Sitecore Search: Configure search service connection
    • For GraphQL: Set up GraphQL endpoint
    • For Content Search: Configure search indexes

Step 2: Frontend Integration

  1. Install Dependencies:

    npm install lucide-react
  2. Add the SearchPage Component to your React application

  3. Configure Routing to include the search page

  4. Customize Content Types to match your portal structure

Step 3: Search Configuration

Option A: Sitecore Search Integration

// Add to your search method
private async Task<object> PerformSitecoreSearch(Dictionary<string, object> searchParams)
{
    var searchClient = new SearchClient(/* your config */);
    var searchRequest = new SearchRequest
    {
        Query = searchParams["query"].ToString(),
        Rows = (int)searchParams["pageSize"],
        Start = ((int)searchParams["page"] - 1) * (int)searchParams["pageSize"]
    };
    
    var response = await searchClient.SearchAsync(searchRequest);
    return response;
}

Option B: GraphQL Integration

// GraphQL query example
private string BuildGraphQLQuery(Dictionary<string, object> searchParams)
{
    return $@"
    {{
        search(
            query: ""{searchParams["query"]}""
            first: {searchParams["pageSize"]}
            after: ""{((int)searchParams["page"] - 1) * (int)searchParams["pageSize"]}""
        ) {{
            results {{
                id
                name
                url
                fields {{
                    name
                    value
                }}
            }}
            total
        }}
    }}";
}

Advanced Features

Search Enhancements

  1. Autocomplete/Suggestions:

    • Add debounced search as user types
    • Implement search suggestions dropdown
  2. Faceted Search:

    • Add category-based filtering
    • Date range filters
    • Author filtering
  3. Search Analytics:

    • Track search queries
    • Monitor search performance
    • Analyze popular content

Performance Optimizations

  1. Caching:

    • Implement result caching
    • Use Redis for distributed caching
  2. Indexing:

    • Optimize Sitecore search indexes
    • Regular index rebuilding
  3. Frontend Optimization:

    • Implement virtual scrolling for large result sets
    • Add result caching with React Query

Customization Options

Styling Customization

The component uses Tailwind CSS classes that can be customized:

// Custom theme colors
const searchButtonClass = "bg-your-primary-color hover:bg-your-primary-dark";
const filterButtonClass = "bg-your-secondary-color text-white";

Content Type Configuration

Modify the contentTypes array to match your Sitecore templates:

const contentTypes = [
  { value: 'YourTemplate1', label: 'Custom Content Type 1' },
  { value: 'YourTemplate2', label: 'Custom Content Type 2' },
  // Add your specific content types
];

Search Result Customization

Customize the result display based on your content structure:

// Custom result rendering
const renderResult = (result) => (
  <div className="custom-result-layout">
    <h3>{result.Title}</h3>
    <p>{result.Description}</p>
    <div className="custom-metadata">
      <span>{result.Author}</span>
      <span>{result.Category}</span>
    </div>
  </div>
);

Troubleshooting

Common Issues

  1. Search Not Working:

    • Check API endpoint configuration
    • Verify search service connectivity
    • Ensure proper CORS settings
  2. No Results Returned:

    • Verify search indexes are populated
    • Check content permissions
    • Validate search query syntax
  3. Performance Issues:

    • Optimize search indexes
    • Implement proper caching
    • Use pagination effectively

Debug Tips

  1. Enable API Logging:

    // Add logging to your search controller
    _logger.LogInformation($"Search query: {query}, Results: {results.Count}");
  2. Frontend Debugging:

    // Add console logging
    console.log('Search params:', { query, page, filters });
    console.log('Search results:', searchResults);

Conclusion

This implementation provides a comprehensive search solution for your Sitecore headless portal. The modular design allows for easy customization and extension based on your specific requirements. The combination of a robust backend API and a modern React frontend ensures a smooth user experience while maintaining scalability and performance.

The solution supports multiple search approaches, making it flexible enough to work with different Sitecore configurations and search requirements. Regular updates and optimizations will help maintain search performance as your content grows.

⚠️ **GitHub.com Fallback** ⚠️