{"id":112083,"date":"2025-02-26T22:37:46","date_gmt":"2025-02-26T22:37:46","guid":{"rendered":"https:\/\/peraltafinancing.com\/analytics\/static-site-search-with-hugo-app-engine-search-api-python\/"},"modified":"2025-02-26T22:37:46","modified_gmt":"2025-02-26T22:37:46","slug":"static-site-search-with-hugo-app-engine-search-api-python","status":"publish","type":"post","link":"https:\/\/fivemor.com\/?p=112083","title":{"rendered":"Static Site Search With Hugo + App Engine + Search API + Python"},"content":{"rendered":"<p> <br \/>\n<\/p>\n<div>\n<p>As the year changed to 2018, I decided to abandon <a href=\"https:\/\/www.wordpress.org\">WordPress<\/a>, which I had been using for over 12 years as my content management system of choice. I had many reasons to do so, but the biggest motivators were the opportunity to try something new and to abandon the bloat and clutter of WordPress for a more simple, more elegant order of things. Spurred on by another adopter, <a href=\"http:\/\/markedmondson.me\/\">Mark Edmondson<\/a>, I decided to give <a href=\"https:\/\/gohugo.io\/\">Hugo<\/a> a go (pun intended).<\/p>\n<div style=\"aspect-ratio: 2120 \/ 670;\" class=\"figure nocaption\">\n<p>    <a href=\"https:\/\/www.simoahava.com\/images\/2018\/01\/hugo.jpg\" title=\"Hugo static site generator\"><\/p>\n<p>    <img decoding=\"async\" class=\"fig-img\" height=\"670\" width=\"2120\" loading=\"lazy\" src=\"https:\/\/www.simoahava.com\/images\/2018\/01\/hugo.jpg#ZgotmplZ\" alt=\"Hugo static site generator\"\/><\/p>\n<p>    <\/a><\/p>\n<\/div>\n<p>The migration wasn\u2019t easy, as I had to convert WordPress\u2019 relational database into a static list of markdown files. Along the way, I had to configure two themes (<a href=\"https:\/\/themes.gohugo.io\/hugo-tranquilpeak-theme\/\">regular<\/a> and <a href=\"https:\/\/github.com\/DanielMuller\/hugo-theme-campser\">AMP<\/a>), optimize all my images, JavaScript, and stylesheets, and go through every single one of my 200+ articles, looking for stylistic issues and broken links (oh boy, were there many of those!).<\/p>\n<p>Hugo is written in the <a href=\"https:\/\/golang.org\/\">Go<\/a> language, and it\u2019s fairly easy to use if you\u2019re familiar with <a href=\"https:\/\/daringfireball.net\/projects\/markdown\/syntax\">markdown<\/a> and the command line of your operating system. The trick about a static site is that all the content is stored in static files in your file server. There is no relational database to fall back on, which means that a static site can be both blazing fast and a chore to maintain.<\/p>\n<p>One of the biggest headaches for me was how to set up site search. Without a database or a web server generating dynamic HTML documents, finding a suitable way to index the content in the browser and respond quickly and efficiently to search queries seemed like an insurmountable task.<\/p>\n<p>I tried a number of things at first, including:<\/p>\n<ul>\n<li>\n<p><a href=\"https:\/\/www.algolia.com\/\">Algolia<\/a>, which I had to give up beacuse I had too much content for their free tier.<\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/lunrjs.com\/\">lunr.js<\/a> running on a NodeJS virtual machine in Google\u2019s cloud, which I had to give up because I got a bill of 400$ for instance upkeep for the month of December alone.<\/p>\n<\/li>\n<li>\n<p>Custom-built solution that digested JSON generated by Hugo and parsed it for searching with jQuery directly in the browser, which I had to give up since downloading an indexed JSON file of around 5 megabytes on every page is not conducive to a good user experience.<\/p>\n<\/li>\n<\/ul>\n<p>After the failed experiment with lunr.js, I still wanted to give Google\u2019s App Engine another chance. I\u2019ve been in love with App Engine ever since publishing the first version of my <a href=\"https:\/\/www.gtmtools.com\/\">GTM Tools<\/a> on it. Well, as it turns out, App Engine has a really useful and flexible <a href=\"https:\/\/cloud.google.com\/appengine\/docs\/standard\/python\/search\/\">Search API<\/a> for Python, which seems to be tailormade to work with the JSON generated by Hugo in a static site!<\/p>\n<div style=\"aspect-ratio: 2878 \/ 1102;\" class=\"figure nocaption\">\n<p>    <a href=\"https:\/\/www.simoahava.com\/images\/2018\/01\/static-site-search-with-google-appengine-search-api.jpg\" title=\"Search API quotas in App Engine\"><\/p>\n<p>    <img decoding=\"async\" class=\"fig-img\" height=\"1102\" width=\"2878\" loading=\"lazy\" src=\"https:\/\/www.simoahava.com\/images\/2018\/01\/static-site-search-with-google-appengine-search-api.jpg#ZgotmplZ\" alt=\"Search API quotas in App Engine\"\/><\/p>\n<p>    <\/a><\/p>\n<\/div>\n<p>                <span class=\"simmer\"><br \/>\n  <span class=\"close\">X<\/span><\/p>\n<p>\n    <span class=\"fa fa-md fa-bell\"\/><br \/>\n    <strong>The Simmer Newsletter<\/strong>\n  <\/p>\n<p>\n    Subscribe to the <a href=\"https:\/\/www.simoahava.com\/newsletter\/\">Simmer newsletter<\/a> to get the latest news and content from Simo Ahava into your email inbox!\n  <\/p>\n<p>  <\/span><\/p>\n<h2 id=\"the-setup\">The setup<\/h2>\n<p>My setup looks like this:<\/p>\n<ol>\n<li>\n<p>The <a href=\"https:\/\/gohugo.io\/templates\/output-formats\/#output-formats\">Hugo config file<\/a> is configured to output an <code>index.json<\/code> into the public directory, with all the content of my site ready for indexing.<\/p>\n<\/li>\n<li>\n<p>A script which deploys this JSON file into the App Engine project.<\/p>\n<\/li>\n<li>\n<p>An App Engine project which uses the <a href=\"https:\/\/cloud.google.com\/appengine\/docs\/standard\/python\/search\/\">Python Search API<\/a> client to build an index of this JSON.<\/p>\n<\/li>\n<li>\n<p>The App Engine project also provides an HTTP endpoint to which my site makes all the search queries. Each request is processed as a search query, and the result is returned in the HTTP response.<\/p>\n<\/li>\n<li>\n<p>Finally, I have a bunch of JavaScript running the search form and the search results page on my site, sending the request to the App Engine endpoint as well as formatting the search results page with the response.<\/p>\n<\/li>\n<\/ol>\n<p>The beauty of using the Search API is that I\u2019m well below the <a href=\"https:\/\/cloud.google.com\/appengine\/docs\/standard\/python\/search\/#Python_Search_API_quotas\">quota limits<\/a> for the free version, and thus I don\u2019t have to pay a dime to make it all work!<\/p>\n<div style=\"aspect-ratio: 1402 \/ 282;\" class=\"figure nocaption\">\n<p>    <a href=\"https:\/\/www.simoahava.com\/images\/2018\/01\/search-quota.jpg\" title=\"My quotas for the search API\"><\/p>\n<p>    <img decoding=\"async\" class=\"fig-img\" height=\"282\" width=\"1402\" loading=\"lazy\" src=\"https:\/\/www.simoahava.com\/images\/2018\/01\/search-quota.jpg#ZgotmplZ\" alt=\"My quotas for the search API\"\/><\/p>\n<p>    <\/a><\/p>\n<\/div>\n<h2 id=\"1-the-config-file-modification\">1. The config file modification<\/h2>\n<p>The change to Hugo\u2019s config file is easy to make, because Hugo has built in support for generating the JSON in a format that most search libraries digest. In the configuration file, you need to find the <code>output<\/code> configuration, and add <code>\"JSON\"<\/code> as one of the outputs for the <code>home<\/code> content type. So it looks something like this:<\/p>\n<div class=\"highlight\">\n<pre style=\"background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4\"><code class=\"language-toml\" data-lang=\"toml\">[output]\n  home = [ <span style=\"color:#a50\">\"HTML\"<\/span>, <span style=\"color:#a50\">\"RSS\"<\/span>, <span style=\"color:#a50\">\"JSON\"<\/span> ]<\/code><\/pre>\n<\/div>\n<p>This configuration change generates an <code>index.json<\/code> file into the root of your public folder whenever the Hugo project is built.<\/p>\n<p>Here\u2019s an example of a what a blog post might look like in this file:<\/p>\n<div class=\"highlight\">\n<pre style=\"background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4\"><code class=\"language-json\" data-lang=\"json\">[\n    {\n        <span style=\"color:#1e90ff;font-weight:bold\">\"uri\"<\/span>: <span style=\"color:#a50\">\"https:\/\/www.simoahava.com\/upcoming-talks\/\"<\/span>,\n        <span style=\"color:#1e90ff;font-weight:bold\">\"title\"<\/span>: <span style=\"color:#a50\">\"Upcoming Talks\"<\/span>,\n        <span style=\"color:#1e90ff;font-weight:bold\">\"tags\"<\/span>: [],\n        <span style=\"color:#1e90ff;font-weight:bold\">\"description\"<\/span>: <span style=\"color:#a50\">\"My upcoming conference talks and events\"<\/span>,\n        <span style=\"color:#1e90ff;font-weight:bold\">\"content\"<\/span>: <span style=\"color:#a50\">\"17 March 2018: MeasureCamp London 20 March 2018: SMX M\u00fcnchen 19 April 2018: Advanced GTM Workshop (Hamburg) 24 May 2018: NXT Nordic (Oslo) 20 September 2018: Advanced GTM Workshop (Hamburg) 14-16 November 2018: SMXL Milan    I enjoy presenting at conferences and meetups, and I have a track record of hundreds of talks since 2013, comprising keynotes, conference presentations, workshops, seminars, and public trainings. Audience sizes have varied between 3 and 2,000.\\nMy favorite topics revolve around web analytics development and analytics customization, but I\\u0026rsquo;m more than happy to talk about integrating analytics into organizations, knowledge transfer, improving technical skills, digital marketing, and content creation.\\nSome of my conference slides can be found at SlideShare.\\nFor a sample, here\\u0026rsquo;s a talk I gave at Reaktor Breakpoint in 2015.\\n   You can contact me at simo (at) simoahava.com for enquiring about my availability for your event.\\n\"<\/span>\n    }\n]<\/code><\/pre>\n<\/div>\n<h2 id=\"2-the-deploy-script\">2. The deploy script<\/h2>\n<p>The deploy script is a piece of Bash code which builds the Hugo site, copies the <code>index.json<\/code> into my search project folder, and then deploys the entire search project into App Engine. Here\u2019s what it looks like:<\/p>\n<div class=\"highlight\">\n<pre style=\"background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4\"><code class=\"language-bash\" data-lang=\"bash\"><span style=\"color:#0aa\">cd<\/span> ~\/Documents\/Projects\/www-simoahava-com\/\nrm -rf public\nhugo\ncp public\/index.json ..\/www-simoahava-com-search\/\nrm -rf public\n<span style=\"color:#0aa\">cd<\/span> ~\/Documents\/Projects\/www-simoahava-com-search\/\ngcloud app deploy\ncurl https:\/\/search-www-simoahava-com.appspot.com\/update<\/code><\/pre>\n<\/div>\n<p>The <code>hugo<\/code> command builds the site and generates the public folder. From the public folder, the <code>index.json<\/code> is then copied to my search project folder, which is subsequently deployed into App Engine using the command <code>gcloud app deploy<\/code>. Finally, a <code>curl<\/code> command to my custom endpoint makes certain that my Python script updates the search index with the latest version of <code>index.json<\/code>.<\/p>\n<h2 id=\"3-the-python-code-running-in-app-engine\">3. The Python code running in App Engine<\/h2>\n<p>In <a href=\"https:\/\/cloud.google.com\/appengine\/\">App Engine<\/a>, I\u2019ve simply created a new project with a name that\u2019s easy enough to remember as the endpoint. I haven\u2019t added any billing to the account, because I\u2019ve set a challenge for myself to build a free search API for my site.<\/p>\n<p>See <a href=\"https:\/\/cloud.google.com\/appengine\/docs\/flexible\/python\/quickstart\">this documentation<\/a> for a quick start guide on how to get started with Python and App Engine. Focus especially on how to setup the App Engine project (you don\u2019t need to enable billing), and how to install and configure the <code>gcloud<\/code> command line tools for your project.<\/p>\n<p>The Python code looks like this.<\/p>\n<div class=\"highlight\">\n<pre style=\"background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4\"><code class=\"language-python\" data-lang=\"python\"><span style=\"color:#aaa;font-style:italic\">#!\/usr\/bin\/python<\/span>\n\n<span style=\"color:#00a\">from<\/span> <span style=\"color:#0aa;text-decoration:underline\">urlparse<\/span> <span style=\"color:#00a\">import<\/span> urlparse\n<span style=\"color:#00a\">from<\/span> <span style=\"color:#0aa;text-decoration:underline\">urlparse<\/span> <span style=\"color:#00a\">import<\/span> parse_qs\n\n<span style=\"color:#00a\">import<\/span> <span style=\"color:#0aa;text-decoration:underline\">json<\/span>\n<span style=\"color:#00a\">import<\/span> <span style=\"color:#0aa;text-decoration:underline\">re<\/span>\n\n<span style=\"color:#00a\">import<\/span> <span style=\"color:#0aa;text-decoration:underline\">webapp2<\/span>\n<span style=\"color:#00a\">from<\/span> <span style=\"color:#0aa;text-decoration:underline\">webapp2_extras<\/span> <span style=\"color:#00a\">import<\/span> jinja2\n\n<span style=\"color:#00a\">from<\/span> <span style=\"color:#0aa;text-decoration:underline\">google.appengine.api<\/span> <span style=\"color:#00a\">import<\/span> search\n\n<span style=\"color:#aaa;font-style:italic\"># Index name for your search documents<\/span>\n_INDEX_NAME = <span style=\"color:#a50\">'search-www-simoahava-com'<\/span>\n\n\n<span style=\"color:#00a\">def<\/span> <span style=\"color:#0a0\">create_document<\/span>(title, uri, description, tags, content):\n    <span style=\"color:#a50\">\"\"\"Create a search document with ID generated from the post title\"\"\"<\/span>\n    doc_id = re.sub(<span style=\"color:#a50\">'[\\s+]'<\/span>, <span style=\"color:#a50\">''<\/span>, title)\n    document = search.Document(\n        doc_id=doc_id,\n        fields=[\n            search.TextField(name=<span style=\"color:#a50\">'title'<\/span>, value=title),\n            search.TextField(name=<span style=\"color:#a50\">'uri'<\/span>, value=uri),\n            search.TextField(name=<span style=\"color:#a50\">'description'<\/span>, value=description),\n            search.TextField(name=<span style=\"color:#a50\">'tags'<\/span>, value=json.dumps(tags)),\n            search.TextField(name=<span style=\"color:#a50\">'content'<\/span>, value=content)\n        ]\n    )\n    <span style=\"color:#00a\">return<\/span> document\n\n\n<span style=\"color:#00a\">def<\/span> <span style=\"color:#0a0\">add_document_to_index<\/span>(document):\n    index = search.Index(_INDEX_NAME)\n    index.put(document)\n\t\n\n<span style=\"color:#00a\">class<\/span> <span style=\"color:#0a0;text-decoration:underline\">BaseHandler<\/span>(webapp2.RequestHandler):\n    <span style=\"color:#a50\">\"\"\"The other handlers inherit from this class. Provides some helper methods\n<\/span><span style=\"color:#a50\">    for rendering a template.\"\"\"<\/span>\n\n    <span style=\"color:#888\">@webapp2.cached_property<\/span>\n    <span style=\"color:#00a\">def<\/span> <span style=\"color:#0a0\">jinja2<\/span>(self):\n        <span style=\"color:#00a\">return<\/span> jinja2.get_jinja2(app=self.app)\n\n\n<span style=\"color:#00a\">class<\/span> <span style=\"color:#0a0;text-decoration:underline\">ProcessQuery<\/span>(BaseHandler):\n    <span style=\"color:#a50\">\"\"\"Handles search requests for comments.\"\"\"<\/span>\n\n    <span style=\"color:#00a\">def<\/span> <span style=\"color:#0a0\">get<\/span>(self):\n        <span style=\"color:#a50\">\"\"\"Handles a get request with a query.\"\"\"<\/span>\n        uri = urlparse(self.request.uri)\n        query = <span style=\"color:#a50\">''<\/span>\n        <span style=\"color:#00a\">if<\/span> uri.query:\n            query = parse_qs(uri.query)\n            query = query[<span style=\"color:#a50\">'q'<\/span>][<span style=\"color:#099\">0<\/span>]\n\n        index = search.Index(_INDEX_NAME)\n\n        compiled_query = search.Query(\n            query_string=json.dumps(query),\n            options=search.QueryOptions(\n                sort_options=search.SortOptions(match_scorer=search.MatchScorer()),\n                limit=<span style=\"color:#099\">1000<\/span>,\n                returned_fields=[<span style=\"color:#a50\">'title'<\/span>, <span style=\"color:#a50\">'uri'<\/span>, <span style=\"color:#a50\">'description'<\/span>]\n            )\n        )\n\t\t\n\t\tresults = index.search(compiled_query)\n\n        json_results = {\n            <span style=\"color:#a50\">'results'<\/span>: [],\n            <span style=\"color:#a50\">'query'<\/span>: json.dumps(query)\n        }\n\n        <span style=\"color:#00a\">for<\/span> document <span style=\"color:#00a\">in<\/span> results.results:\n            search_result = {}\n            <span style=\"color:#00a\">for<\/span> field <span style=\"color:#00a\">in<\/span> document.fields:\n                search_result[field.name] = field.value\n            json_results[<span style=\"color:#a50\">'results'<\/span>].append(search_result)\n        self.response.headers.add(<span style=\"color:#a50\">'Access-Control-Allow-Origin'<\/span>, <span style=\"color:#a50\">'https:\/\/www.simoahava.com'<\/span>)\n        self.response.write(json.dumps(json_results))\n\t\t\n\n<span style=\"color:#00a\">class<\/span> <span style=\"color:#0a0;text-decoration:underline\">UpdateIndex<\/span>(BaseHandler):\n    <span style=\"color:#a50\">\"\"\"Updates the index using index.json\"\"\"<\/span>\n\n    <span style=\"color:#00a\">def<\/span> <span style=\"color:#0a0\">get<\/span>(self):\n        <span style=\"color:#00a\">with<\/span> <span style=\"color:#0aa\">open<\/span>(<span style=\"color:#a50\">'index.json'<\/span>) <span style=\"color:#00a\">as<\/span> json_file:\n            data = json.load(json_file)\n\n        <span style=\"color:#00a\">for<\/span> post <span style=\"color:#00a\">in<\/span> data:\n            title = post.get(<span style=\"color:#a50\">'title'<\/span>, <span style=\"color:#a50\">''<\/span>)\n            uri = post.get(<span style=\"color:#a50\">'uri'<\/span>, <span style=\"color:#a50\">''<\/span>)\n            description = post.get(<span style=\"color:#a50\">'description'<\/span>, <span style=\"color:#a50\">''<\/span>)\n            tags = post.get(<span style=\"color:#a50\">'tags'<\/span>, [])\n            content = post.get(<span style=\"color:#a50\">'content'<\/span>, <span style=\"color:#a50\">''<\/span>)\n\n            doc = create_document(title, uri, description, tags, content)\n            add_document_to_index(doc)\n\t\t\t\n\napplication = webapp2.WSGIApplication(\n    [(<span style=\"color:#a50\">'\/'<\/span>, ProcessQuery),\n     (<span style=\"color:#a50\">'\/update'<\/span>, UpdateIndex)],\n    debug=True)<\/code><\/pre>\n<\/div>\n<p>At the very end, I\u2019m binding requests to the <code>\/<\/code> endpoint to <code>ProcessQuery<\/code>, and requests to <code>\/update<\/code> to <code>UpdateIndex<\/code>. In other words, these are the two endpoints I am serving.<\/p>\n<p><code>UpdateIndex<\/code> loads the <code>index.json<\/code> file, and for every single content piece within (blog posts, pages, etc.), it grabs the <code>title<\/code>, <code>uri<\/code>, <code>description<\/code>, <code>tags<\/code>, and <code>content<\/code> parameters from the content JSON, and creates documents for each instance. Then, each document is added to the index.<\/p>\n<p>This is how the Search API can be used to translate any JSON file into a valid search index, that you can then build queries against.<\/p>\n<p>Queries are made by polling the <code>\/?q=<keyword\/><\/code> endpoint, where <code>keyword<\/code> matches a valid query against the Search API <a href=\"https:\/\/cloud.google.com\/appengine\/docs\/standard\/python\/search\/query_strings\">query engine<\/a>. Each query is processed by <code>ProcessQuery<\/code>, which takes the query term, polls the search index with that term, and then compiles a result of all the documents that the search index returns for that query (in ranked order). This result is then pushed into a JSON response back to the client.<\/p>\n<p>The search API gives you plenty of room to optimize the index and for compiling complex queries. I\u2019ve opted for a fairly mundane approach, which might lead to some odd ranking outliers, such as documents that should clearly be at the top of a list of relevant results ending up in the end, but I\u2019m still quite happy with how the robustness of the API.<\/p>\n<h2 id=\"4-the-javascript\">4. The JavaScript<\/h2>\n<p>Finally, I need some client-side code to produce the search results page. Since Hugo doesn\u2019t have a web server, I can\u2019t do the search server-side &#8211; it must be done in the client. This is one of the cases where a static site loses some of its shine when compared to its counterparts that come equipped with a web server and server-side processing capabilities. A Hugo site is built and deployed all at once, so there\u2019s no dynamic generation of HTML pages after building &#8211; everything has to happen in the client.<\/p>\n<p>Anyway, the search form on my site is very simple. It just looks like this:<\/p>\n<div class=\"highlight\">\n<pre style=\"background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4\"><code class=\"language-html\" data-lang=\"html\">form <span style=\"color:#1e90ff\">id<\/span>=<span style=\"color:#a50\">\"search\"<\/span> <span style=\"color:#1e90ff\">action<\/span>=<span style=\"color:#a50\">\"\/search\/\"<\/span>&gt;\n  input <span style=\"color:#1e90ff\">name<\/span>=<span style=\"color:#a50\">\"q\"<\/span> <span style=\"color:#1e90ff\">type<\/span>=<span style=\"color:#a50\">\"text\"<\/span> <span style=\"color:#1e90ff\">class<\/span>=<span style=\"color:#a50\">\"form-control input--xlarge\"<\/span> <span style=\"color:#1e90ff\">placeholder<\/span>=<span style=\"color:#a50\">\"Search blog...\"<\/span> <span style=\"color:#1e90ff\">autocomplete<\/span>=<span style=\"color:#a50\">\"off\"<\/span>&gt;\n<span style=\"color:#1e90ff;font-weight:bold\">form<\/span>&gt;<\/code><\/pre>\n<\/div>\n<p>When the form is submitted, it does a GET request to the <code>\/search\/<\/code> page on my site, adding whatever was typed into the field as the <code>q<\/code> query parameter, so the URL becomes something like<\/p>\n<p><code>https:\/\/www.simoahava.com\/search\/?q=google+tag+manager<\/code><\/p>\n<p>On the <code>\/search\/<\/code> page, I have a loading spinner which waits until the request to the search endpoint is completed. The search call is done with JavaScript, and it looks like this:<\/p>\n<div class=\"highlight\">\n<pre style=\"background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4\"><code class=\"language-javascript\" data-lang=\"javascript\">(<span style=\"color:#00a\">function<\/span>($) {\n\n    <span style=\"color:#00a\">var<\/span> printSearchResults = <span style=\"color:#00a\">function<\/span>(results) {\n\t  <span style=\"color:#aaa;font-style:italic\">\/\/ Update the page DOM with the search results...\n<\/span><span style=\"color:#aaa;font-style:italic\"\/>\t};\n\n    <span style=\"color:#00a\">var<\/span> endpoint = <span style=\"color:#a50\">'https:\/\/search-www-simoahava-com.appspot.com'<\/span>;\n\n    <span style=\"color:#00a\">var<\/span> getQuery = <span style=\"color:#00a\">function<\/span>() {\n        <span style=\"color:#00a\">if<\/span> (<span style=\"color:#0aa\">window<\/span>.location.search.length === <span style=\"color:#099\">0<\/span> || !<span style=\"color:#099\">\/(\\?|&amp;)q=\/<\/span>.test(<span style=\"color:#0aa\">window<\/span>.location.search)) {\n            <span style=\"color:#00a\">return<\/span> <span style=\"color:#00a\">undefined<\/span>;\n        }\n\n        <span style=\"color:#00a\">var<\/span> parts = <span style=\"color:#0aa\">window<\/span>.location.search.substring(<span style=\"color:#099\">1<\/span>).split(<span style=\"color:#a50\">'&amp;'<\/span>);\n        <span style=\"color:#00a\">var<\/span> query = parts.map(<span style=\"color:#00a\">function<\/span>(part) {\n            <span style=\"color:#00a\">var<\/span> temp = part.split(<span style=\"color:#a50\">'='<\/span>);\n            <span style=\"color:#00a\">return<\/span> temp[<span style=\"color:#099\">0<\/span>] === <span style=\"color:#a50\">'q'<\/span> ? temp[<span style=\"color:#099\">1<\/span>] : <span style=\"color:#00a\">false<\/span>;\n        });\n        <span style=\"color:#00a\">return<\/span> query[<span style=\"color:#099\">0<\/span>] || <span style=\"color:#00a\">undefined<\/span>;\n    };\n\n    $(<span style=\"color:#0aa\">document<\/span>).ready(<span style=\"color:#00a\">function<\/span>() {\n        <span style=\"color:#00a\">var<\/span> query = getQuery();\n\n        <span style=\"color:#00a\">if<\/span> (<span style=\"color:#00a\">typeof<\/span> query === <span style=\"color:#a50\">'undefined'<\/span>) {\n            printSearchResults();\n            <span style=\"color:#00a\">return<\/span>;\n        } <span style=\"color:#00a\">else<\/span> {\n            $.get(endpoint + <span style=\"color:#a50\">'?q='<\/span> + query, <span style=\"color:#00a\">function<\/span>(data) {\n                printSearchResults(JSON.parse(data));\n            });\n        }\n    });\n\t\n})(<span style=\"color:#0aa\">window<\/span>.jQuery)\n<\/code><\/pre>\n<\/div>\n<p>To keep things simple, I\u2019ve only included the relevant pieces of code that can be used elsewhere, too. In short, when the <code>\/search\/<\/code> page is loaded, whatever is included as the value of the <code>q<\/code> query parameter is immediately sent to the search API endpoint. The response is then processed and built into a search results page.<\/p>\n<p>So, if the page URL is <code>https:\/\/www.simoahava.com\/search\/?q=google+tag.manager<\/code>, this piece of JavaScript turns that into a GET request to <code>https:\/\/search-www-simoahava-com.appspot.com\/?q=google+tag+manager<\/code>. You can visit that URL to see what the response looks like.<\/p>\n<p>This response is the processed, and the search results page is built.<\/p>\n<h2 id=\"summary\">Summary<\/h2>\n<p>This is how I\u2019ve chosen to build site search using the flexibility of Hugo together with the powerful Search API offered by Google\u2019s App Engine.<\/p>\n<p>Based on my limited amount of research, it\u2019s as good a solution as any, and it seems quite fast without compromising the power of the search query engine. However, as more content builds up, it\u2019s conceivable that the query engine either gets slower or I start hitting my free tier quotas, at which point I\u2019ll need to rethink my approach.<\/p>\n<p>The weak link at the moment is that everything is done client-side. That means that contrary to the philosphy of static sites, a lot of processing takes place in the browser. But I\u2019m not sure how this could be avoided, since a static site doesn\u2019t offer you the capabilities of a server-side processor.<\/p>\n<p>At this time, it\u2019s a trade-off I\u2019m willing to make, but I am anxious to hear feedback if the search is clumsy or not working properly for you.<\/p>\n<\/p><\/div>\n\n","protected":false},"excerpt":{"rendered":"<p>As the year changed to 2018, I decided to abandon WordPress, which I had been using for over 12 years as my content management system of choice. I had many reasons to do so, but the biggest motivators were the opportunity to try something new and to abandon the bloat and clutter of WordPress for [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":112085,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[12033],"tags":[13966,4364,21739,239,21302,5476,7476,16232],"dealstore":[],"offerexpiration":[],"class_list":["post-112083","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-analytics","tag-api","tag-app","tag-engine","tag-hugo","tag-python","tag-search","tag-site","tag-static"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v26.4 - https:\/\/yoast.com\/wordpress\/plugins\/seo\/ -->\n<title>Static Site Search With Hugo + App Engine + Search API + Python - Som2ny Network<\/title>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/fivemor.com\/?p=112083\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Static Site Search With Hugo + App Engine + Search API + Python - Som2ny Network\" \/>\n<meta property=\"og:description\" content=\"As the year changed to 2018, I decided to abandon WordPress, which I had been using for over 12 years as my content management system of choice. I had many reasons to do so, but the biggest motivators were the opportunity to try something new and to abandon the bloat and clutter of WordPress for [&hellip;]\" \/>\n<meta property=\"og:url\" content=\"https:\/\/fivemor.com\/?p=112083\" \/>\n<meta property=\"og:site_name\" content=\"Som2ny Network\" \/>\n<meta property=\"article:published_time\" content=\"2025-02-26T22:37:46+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/fivemor.com\/wp-content\/uploads\/2025\/02\/hugo.jpg\" \/>\n\t<meta property=\"og:image:width\" content=\"2120\" \/>\n\t<meta property=\"og:image:height\" content=\"670\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/jpeg\" \/>\n<meta name=\"author\" content=\"admin\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"admin\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"12 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\/\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\/\/fivemor.com\/?p=112083#article\",\"isPartOf\":{\"@id\":\"https:\/\/fivemor.com\/?p=112083\"},\"author\":{\"name\":\"admin\",\"@id\":\"https:\/\/fivemor.com\/#\/schema\/person\/b85e3c3dc0e1daea076524dc8810c371\"},\"headline\":\"Static Site Search With Hugo + App Engine + Search API + Python\",\"datePublished\":\"2025-02-26T22:37:46+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\/\/fivemor.com\/?p=112083\"},\"wordCount\":1613,\"commentCount\":0,\"publisher\":{\"@id\":\"https:\/\/fivemor.com\/#organization\"},\"image\":{\"@id\":\"https:\/\/fivemor.com\/?p=112083#primaryimage\"},\"thumbnailUrl\":\"https:\/\/fivemor.com\/wp-content\/uploads\/2025\/02\/hugo.jpg\",\"keywords\":[\"API\",\"APP\",\"ENGINE\",\"HUGO\",\"Python\",\"search\",\"SITE\",\"Static\"],\"articleSection\":[\"Analytics\"],\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"CommentAction\",\"name\":\"Comment\",\"target\":[\"https:\/\/fivemor.com\/?p=112083#respond\"]}]},{\"@type\":\"WebPage\",\"@id\":\"https:\/\/fivemor.com\/?p=112083\",\"url\":\"https:\/\/fivemor.com\/?p=112083\",\"name\":\"Static Site Search With Hugo + App Engine + Search API + Python - Som2ny Network\",\"isPartOf\":{\"@id\":\"https:\/\/fivemor.com\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\/\/fivemor.com\/?p=112083#primaryimage\"},\"image\":{\"@id\":\"https:\/\/fivemor.com\/?p=112083#primaryimage\"},\"thumbnailUrl\":\"https:\/\/fivemor.com\/wp-content\/uploads\/2025\/02\/hugo.jpg\",\"datePublished\":\"2025-02-26T22:37:46+00:00\",\"breadcrumb\":{\"@id\":\"https:\/\/fivemor.com\/?p=112083#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\/\/fivemor.com\/?p=112083\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/fivemor.com\/?p=112083#primaryimage\",\"url\":\"https:\/\/fivemor.com\/wp-content\/uploads\/2025\/02\/hugo.jpg\",\"contentUrl\":\"https:\/\/fivemor.com\/wp-content\/uploads\/2025\/02\/hugo.jpg\",\"width\":2120,\"height\":670},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\/\/fivemor.com\/?p=112083#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\/\/fivemor.com\/?bp_activities=1\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Static Site Search With Hugo + App Engine + Search API + Python\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\/\/fivemor.com\/#website\",\"url\":\"https:\/\/fivemor.com\/\",\"name\":\"Som2ny Network\",\"description\":\"Daily Deals\",\"publisher\":{\"@id\":\"https:\/\/fivemor.com\/#organization\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\/\/fivemor.com\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":\"Organization\",\"@id\":\"https:\/\/fivemor.com\/#organization\",\"name\":\"Som2ny Network\",\"url\":\"https:\/\/fivemor.com\/\",\"logo\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/fivemor.com\/#\/schema\/logo\/image\/\",\"url\":\"https:\/\/fivemor.com\/wp-content\/uploads\/2026\/07\/4a0953c4-logo-300x86-1.png\",\"contentUrl\":\"https:\/\/fivemor.com\/wp-content\/uploads\/2026\/07\/4a0953c4-logo-300x86-1.png\",\"width\":300,\"height\":86,\"caption\":\"Som2ny Network\"},\"image\":{\"@id\":\"https:\/\/fivemor.com\/#\/schema\/logo\/image\/\"}},{\"@type\":\"Person\",\"@id\":\"https:\/\/fivemor.com\/#\/schema\/person\/b85e3c3dc0e1daea076524dc8810c371\",\"name\":\"admin\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/fivemor.com\/#\/schema\/person\/image\/\",\"url\":\"https:\/\/secure.gravatar.com\/avatar\/729ae85bf62b9917e93538db2f2688ca?s=96&r=g&default=https%3A%2F%2Ffivemor.com%2Fwp-content%2Fplugins%2Fbuddypress-first-letter-avatar%2Fimages%2Fdefault%2F96%2Flatin_a.png\",\"contentUrl\":\"https:\/\/secure.gravatar.com\/avatar\/729ae85bf62b9917e93538db2f2688ca?s=96&r=g&default=https%3A%2F%2Ffivemor.com%2Fwp-content%2Fplugins%2Fbuddypress-first-letter-avatar%2Fimages%2Fdefault%2F96%2Flatin_a.png\",\"caption\":\"admin\"},\"sameAs\":[\"https:\/\/fivemor.com\"],\"url\":\"https:\/\/fivemor.com\/?author=1\"}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"Static Site Search With Hugo + App Engine + Search API + Python - Som2ny Network","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/fivemor.com\/?p=112083","og_locale":"en_US","og_type":"article","og_title":"Static Site Search With Hugo + App Engine + Search API + Python - Som2ny Network","og_description":"As the year changed to 2018, I decided to abandon WordPress, which I had been using for over 12 years as my content management system of choice. I had many reasons to do so, but the biggest motivators were the opportunity to try something new and to abandon the bloat and clutter of WordPress for [&hellip;]","og_url":"https:\/\/fivemor.com\/?p=112083","og_site_name":"Som2ny Network","article_published_time":"2025-02-26T22:37:46+00:00","og_image":[{"width":2120,"height":670,"url":"https:\/\/fivemor.com\/wp-content\/uploads\/2025\/02\/hugo.jpg","type":"image\/jpeg"}],"author":"admin","twitter_card":"summary_large_image","twitter_misc":{"Written by":"admin","Est. reading time":"12 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/fivemor.com\/?p=112083#article","isPartOf":{"@id":"https:\/\/fivemor.com\/?p=112083"},"author":{"name":"admin","@id":"https:\/\/fivemor.com\/#\/schema\/person\/b85e3c3dc0e1daea076524dc8810c371"},"headline":"Static Site Search With Hugo + App Engine + Search API + Python","datePublished":"2025-02-26T22:37:46+00:00","mainEntityOfPage":{"@id":"https:\/\/fivemor.com\/?p=112083"},"wordCount":1613,"commentCount":0,"publisher":{"@id":"https:\/\/fivemor.com\/#organization"},"image":{"@id":"https:\/\/fivemor.com\/?p=112083#primaryimage"},"thumbnailUrl":"https:\/\/fivemor.com\/wp-content\/uploads\/2025\/02\/hugo.jpg","keywords":["API","APP","ENGINE","HUGO","Python","search","SITE","Static"],"articleSection":["Analytics"],"inLanguage":"en-US","potentialAction":[{"@type":"CommentAction","name":"Comment","target":["https:\/\/fivemor.com\/?p=112083#respond"]}]},{"@type":"WebPage","@id":"https:\/\/fivemor.com\/?p=112083","url":"https:\/\/fivemor.com\/?p=112083","name":"Static Site Search With Hugo + App Engine + Search API + Python - Som2ny Network","isPartOf":{"@id":"https:\/\/fivemor.com\/#website"},"primaryImageOfPage":{"@id":"https:\/\/fivemor.com\/?p=112083#primaryimage"},"image":{"@id":"https:\/\/fivemor.com\/?p=112083#primaryimage"},"thumbnailUrl":"https:\/\/fivemor.com\/wp-content\/uploads\/2025\/02\/hugo.jpg","datePublished":"2025-02-26T22:37:46+00:00","breadcrumb":{"@id":"https:\/\/fivemor.com\/?p=112083#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/fivemor.com\/?p=112083"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/fivemor.com\/?p=112083#primaryimage","url":"https:\/\/fivemor.com\/wp-content\/uploads\/2025\/02\/hugo.jpg","contentUrl":"https:\/\/fivemor.com\/wp-content\/uploads\/2025\/02\/hugo.jpg","width":2120,"height":670},{"@type":"BreadcrumbList","@id":"https:\/\/fivemor.com\/?p=112083#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/fivemor.com\/?bp_activities=1"},{"@type":"ListItem","position":2,"name":"Static Site Search With Hugo + App Engine + Search API + Python"}]},{"@type":"WebSite","@id":"https:\/\/fivemor.com\/#website","url":"https:\/\/fivemor.com\/","name":"Som2ny Network","description":"Daily Deals","publisher":{"@id":"https:\/\/fivemor.com\/#organization"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/fivemor.com\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":"Organization","@id":"https:\/\/fivemor.com\/#organization","name":"Som2ny Network","url":"https:\/\/fivemor.com\/","logo":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/fivemor.com\/#\/schema\/logo\/image\/","url":"https:\/\/fivemor.com\/wp-content\/uploads\/2026\/07\/4a0953c4-logo-300x86-1.png","contentUrl":"https:\/\/fivemor.com\/wp-content\/uploads\/2026\/07\/4a0953c4-logo-300x86-1.png","width":300,"height":86,"caption":"Som2ny Network"},"image":{"@id":"https:\/\/fivemor.com\/#\/schema\/logo\/image\/"}},{"@type":"Person","@id":"https:\/\/fivemor.com\/#\/schema\/person\/b85e3c3dc0e1daea076524dc8810c371","name":"admin","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/fivemor.com\/#\/schema\/person\/image\/","url":"https:\/\/secure.gravatar.com\/avatar\/729ae85bf62b9917e93538db2f2688ca?s=96&r=g&default=https%3A%2F%2Ffivemor.com%2Fwp-content%2Fplugins%2Fbuddypress-first-letter-avatar%2Fimages%2Fdefault%2F96%2Flatin_a.png","contentUrl":"https:\/\/secure.gravatar.com\/avatar\/729ae85bf62b9917e93538db2f2688ca?s=96&r=g&default=https%3A%2F%2Ffivemor.com%2Fwp-content%2Fplugins%2Fbuddypress-first-letter-avatar%2Fimages%2Fdefault%2F96%2Flatin_a.png","caption":"admin"},"sameAs":["https:\/\/fivemor.com"],"url":"https:\/\/fivemor.com\/?author=1"}]}},"_links":{"self":[{"href":"https:\/\/fivemor.com\/index.php?rest_route=\/wp\/v2\/posts\/112083","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/fivemor.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/fivemor.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/fivemor.com\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/fivemor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=112083"}],"version-history":[{"count":0,"href":"https:\/\/fivemor.com\/index.php?rest_route=\/wp\/v2\/posts\/112083\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/fivemor.com\/index.php?rest_route=\/wp\/v2\/media\/112085"}],"wp:attachment":[{"href":"https:\/\/fivemor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=112083"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/fivemor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=112083"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/fivemor.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=112083"},{"taxonomy":"dealstore","embeddable":true,"href":"https:\/\/fivemor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fdealstore&post=112083"},{"taxonomy":"offerexpiration","embeddable":true,"href":"https:\/\/fivemor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fofferexpiration&post=112083"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}