Static sites built with Gatsby offer exceptional performance and security, but implementing search functionality requires careful consideration. Unlike server-side solutions, client-side search with Lunr.js enables full-text search capabilities without external services. This guide walks through building a complete search implementation for your Gatsby website.
For teams building static websites with modern JavaScript frameworks, implementing robust search functionality is essential for content-rich sites. Lunr.js provides a lightweight, self-contained solution that maintains the performance benefits of static hosting.
Understanding Lunr.js for Static Sites
Lunr.js is a lightweight JavaScript library that provides full-text search functionality directly in the browser. For Gatsby static sites, this means search works without any server-side infrastructure, maintaining the core Jamstack benefits of performance and simplicity.
Why Choose Client-Side Search
Client-side search with Lunr.js offers several advantages for static websites. First, there's no need for external search services or APIs, eliminating recurring costs and reducing dependencies. The search index is built at build time and bundled with your site, enabling instant search responses without network latency. Since all search logic runs in the browser, there are no server-side search queries to manage or scale.
For content-driven Gatsby sites with hundreds or thousands of pages, Lunr.js provides an efficient solution. The index stores word-to-document mappings, allowing quick retrieval of matching content without server round-trips.
How Lunr.js Works
Lunr.js uses an inverted index as its core data structure. This index stores mappings from words to the documents containing them, enabling fast lookups. When you search for a term, Lunr.js doesn't scan every document--instead, it consults the index to find matching documents immediately.
The library allows you to define which fields to index (title, content, description) and provides options for boosting certain fields to influence search relevance. This flexibility means you can tune search results to prioritize titles over body content, for example. For more details on Lunr.js capabilities, refer to the official Lunr.js documentation.
Setting Up Your Gatsby Project
Before implementing search, ensure your Gatsby project is ready. The implementation works with any Gatsby site, but for demonstration purposes, we'll reference the official Gatsby blog starter as a reference point.
1npm install lunr graphql-type-json striptagsThe striptags package is essential for clean indexing. When extracting content from HTML, you'll want to strip markup tags to avoid indexing HTML syntax as searchable content. This ensures your search results contain actual page content, not HTML elements.
This approach aligns with best practices for search engine optimization in static site development, ensuring your content remains both searchable and properly indexed.
Creating the Search Index in Gatsby Node
The search index is generated during Gatsby's build process, leveraging the node API to access all site content and create a searchable structure.
Using Gatsby's createResolvers API
1const { GraphQLJSONObject } = require(`graphql-type-json`)2const striptags = require(`striptags`)3const lunr = require(`lunr`)4 5exports.createResolvers = ({ cache, createResolvers }) => {6 createResolvers({7 Query: {8 LunrIndex: {9 type: GraphQLJSONObject,10 resolve: (source, args, context, info) => {11 const blogNodes = context.nodeModel.getAllNodes({12 type: `MarkdownRemark`,13 })14 const type = info.schema.getType(`MarkdownRemark`)15 return createIndex(blogNodes, type, cache)16 },17 },18 },19 })20}This resolver queries all MarkdownRemark nodes (or your content nodes of choice), processes them through the index creation function, and returns the serialized index ready for client-side use. This implementation pattern follows the approach documented in the CSS-Tricks guide on implementing Lunr.js search in Gatsby.
Building the Index Function
The createIndex function processes all content nodes, extracting searchable text and generating the Lunr.js index structure.
1const createIndex = async (blogNodes, type, cache) => {2 const documents = []3 const store = {}4 5 for (const node of blogNodes) {6 const {slug} = node.fields7 const title = node.frontmatter.title8 const [html, excerpt] = await Promise.all([9 type.getFields().html.resolve(node),10 type.getFields().excerpt.resolve(node, { pruneLength: 40 }),11 ])12 13 documents.push({14 slug,15 title,16 content: striptags(html),17 })18 19 store[slug] = {20 title,21 excerpt,22 }23 }24 25 const index = lunr(function() {26 this.ref(`slug`)27 this.field(`title`)28 this.field(`content`)29 for (const doc of documents) {30 this.add(doc)31 }32 })33 34 return { index: index.toJSON(), store }35}The function serves two purposes. First, it builds the Lunr index with title and content fields for searching. Second, it creates a store mapping each document slug to its title and excerpt--information that Lunr doesn't include in search results but is essential for displaying meaningful results to users.
This dual-purpose approach, as demonstrated in the CSS-Tricks implementation guide, ensures your search results are both accurate and user-friendly.
Building the Search Form Component
The search form provides the user interface for entering queries. We'll create a reusable component that works with both traditional form submission and instant search patterns.
Creating the SearchForm Component
1import React, { useState, useRef } from "react"2import { navigate } from "@reach/router"3 4const SearchForm = ({ initialQuery = "" }) => {5 const [query, setQuery] = useState(initialQuery)6 const inputEl = useRef(null)7 8 const handleChange = e => {9 setQuery(e.target.value)10 }11 12 const handleSubmit = e => {13 e.preventDefault()14 const q = inputEl.current.value15 navigate(`/search?q=${q}`)16 }17 18 return (19 <form role="search" onSubmit={handleSubmit}>20 <label htmlFor="search-input" style={{ display: "block" }}>21 Search for:22 </label>23 <input24 ref={inputEl}25 id="search-input"26 type="search"27 value={query}28 placeholder="Search content..."29 onChange={handleChange}30 />31 <button type="submit">Search</button>32 </form>33 )34}35 36export default SearchFormThis component uses React hooks for state management and supports both immediate updates (for instant search) and traditional form submission. The @reach/router package, included with Gatsby, handles navigation to the search results page with the query parameter.
Accessibility Considerations
The form includes proper labeling with htmlFor linking the label to the input. The role="search" attribute identifies the landmark for assistive technologies. These accessibility features ensure all users can effectively use your search functionality.
Implementing accessible search is a key aspect of inclusive web development practices that serve all users effectively.
Implementing the Search Results Page
The search results page receives the query, executes the search against the Lunr index, and displays matching results.
Querying the Index
1import React from "react"2import { Link, graphql } from "gatsby"3import { Index } from "lunr"4import Layout from "../components/layout"5import SEO from "../components/seo"6import SearchForm from "../components/search-form"7 8const SearchPage = ({ data, location }) => {9 const siteTitle = data.site.siteMetadata.title10 11 const params = new URLSearchParams(location.search.slice(1))12 const q = params.get("q") || ""13 14 const { store } = data.LunrIndex15 const index = Index.load(data.LunrIndex.index)16 17 let results = []18 try {19 results = index.search(q).map(({ ref }) => {20 return {21 slug: ref,22 ...store[ref],23 }24 })25 } catch (error) {26 console.log(error)27 }28 29 return (30 <Layout location={location} title={siteTitle}>31 <SEO title="Search results" />32 {q ? <h1>Search results</h1> : <h1>What are you looking for?</h1>}33 <SearchForm initialQuery={q} />34 {results.length ? (35 results.map(result => (36 <article key={result.slug}>37 <h2>38 <Link to={result.slug}>39 {result.title || result.slug}40 </Link>41 </h2>42 <p>{result.excerpt}</p>43 </article>44 ))45 ) : (46 <p>No results found.</p>47 )}48 </Layout>49 )50}51 52export default SearchPage53 54export const pageQuery = graphql`55 query {56 site {57 siteMetadata {58 title59 }60 }61 LunrIndex62 }63`The page queries the LunrIndex field, loads the index using Lunr.js's Index.load() method, and executes the search. Results are mapped to include display information from the store, providing users with clickable titles and relevant excerpts.
This implementation follows established patterns for building efficient search functionality in Gatsby sites, ensuring users can quickly find the content they're looking for.
Adding Instant Search Functionality
For sites with rich content, instant search--updating results as the user types--provides a superior user experience. This requires a different component structure that uses useStaticQuery since it's not a page component.
Creating the Search Widget
The instant search widget uses useStaticQuery since it's not a page component. Results update on every keystroke, providing immediate feedback to users.
1import React, { useState } from "react"2import { Index } from "lunr"3import { graphql, useStaticQuery } from "gatsby"4 5const SearchWidget = () => {6 const [value, setValue] = useState("")7 const [results, setResults] = useState([])8 9 const { LunrIndex } = useStaticQuery(graphql`10 query {11 LunrIndex12 }13 `)14 15 const index = Index.load(LunrIndex.index)16 const { store } = LunrIndex17 18 const handleChange = e => {19 const query = e.target.value20 setValue(query)21 try {22 const searchResults = index.search(query).map(({ ref }) => {23 return {24 slug: ref,25 ...store[ref],26 }27 })28 setResults(searchResults)29 } catch (error) {30 console.log(error)31 }32 }33 34 return (35 <div className="search-wrapper">36 <div role="search">37 <label htmlFor="search-input" className="visually-hidden">38 Search content39 </label>40 <input41 id="search-input"42 type="search"43 value={value}44 onChange={handleChange}45 placeholder="Search..."46 />47 </div>48 <SearchResults results={results} />49 </div>50 )51}Performance Considerations
Client-side search requires careful attention to performance, as the index and search logic execute in users' browsers.
Index Size Management
The search index grows with your content. For sites with thousands of pages, consider segmenting indexes by content type or category. Gatsby's node API allows filtering which nodes to include in the index. For larger sites, implementing search result pagination and limiting index fields to essential content reduces bundle size while maintaining search quality.
Bundle Optimization
Lazy load the Lunr.js library and search components to avoid impacting initial page load times. The search functionality only needs to load when users interact with search, not on initial page render. Dynamic imports with React.lazy() allow you to defer loading until needed.
Search Query Efficiency
Lunr.js supports advanced query syntax including wildcards, boosting, and boolean operators. For most use cases, simple term matching provides excellent results. Avoid complex queries that might slow down the search experience. You can boost title matches over content matches using syntax like ${query}^3 for prioritized results.
These performance optimizations align with best practices for Gatsby performance optimization, ensuring your site remains fast and responsive.
Best Practices for Content Indexing
Effective search depends on quality content indexing. Follow these practices for optimal results.
Clean Content Extraction
Always strip HTML tags from content before indexing. Raw HTML includes markup that shouldn't be searchable and can bloat the index unnecessarily. The striptags package handles this efficiently by removing HTML markup while preserving text content.
Multiple Field Indexing
Index multiple fields to improve search relevance. Title matches should carry more weight than body content matches, and you might include tags, categories, or custom fields for enhanced searchability. Use the field() method in Lunr.js to define which document fields are searchable.
Excerpt Generation
Store excerpts with your documents for display in search results. This provides context for why a result matched and helps users decide which result to click. Generate excerpts during indexing rather than on search time to improve performance.
Conclusion
Implementing Lunr.js search in Gatsby provides a powerful, self-contained search solution without external dependencies. By creating the index during build time and executing searches client-side, you maintain the performance and simplicity that static sites offer while adding sophisticated search functionality.
The key to success lies in thoughtful index design--choosing which fields to index, how to weight them, and how to present results. With the implementation patterns covered in this guide, you can adapt Lunr.js search to any Gatsby project, from simple blogs to complex documentation sites.
Looking to implement advanced search functionality on your website? Our web development team specializes in building high-performance static sites with custom features like full-text search.
No External Services
Full-text search without API costs or vendor dependencies
Instant Results
Client-side search provides immediate response times
Full Control
Customize indexing, weighting, and result display
Static Site Compatible
Works perfectly with Gatsby's build-time approach
Frequently Asked Questions
Is Lunr.js suitable for large websites?
Lunr.js works well for sites with hundreds to a few thousand pages. For very large sites, consider segmenting indexes by content type or implementing pagination in results.
Does Lunr.js work offline?
Yes, since all search logic runs client-side, Lunr.js search works without an internet connection after the initial page load.
How does Lunr.js compare to Algolia?
Algolia offers more features and better scaling for massive sites but requires an external service and recurring costs. Lunr.js is self-contained and free but requires more manual optimization for large datasets.
Can I customize search result ranking?
Yes, Lunr.js allows boosting specific fields and using query modifiers to influence relevance scoring for your content.