Nimbu Developer Docs
Other

Advanced Features

Site search, customer login providers, analytics integration, payment gateways, and security features

Nimbu provides advanced features for search, authentication, analytics, payments, and security to build sophisticated web applications.

Nimbu has two ways to add site search to a theme:

  • The /search page: a GET /search?q=... request renders templates/search.liquid with the results already loaded.
  • The {% search %} tag: runs a query anywhere in a template, for example for a live-search endpoint or a "related articles" block.

The search page (templates/search.liquid)

Point a search form at /search (with the locale prefix on multilingual sites):

<form action="{{ locale_url_prefix }}/search" method="get" role="search">
  <input type="search" name="q" value="{{ params.q | escape }}" placeholder="Search...">
  <button type="submit">Search</button>
</form>

templates/search.liquid receives a results hash:

KeyContents
results.productsMatching products (active, sold out or coming soon)
results.pagesMatching published pages
results.articlesMatching blog articles
results.dbMatching channel entries (see below)
results.allAll of the above in one list
results.any?true when all has at least one result
results.products?, results.pages?, results.articles?, results.db?true when that list has results

Each list holds up to 100 results; ?page=2 fetches the next 100. Every result has a title and a url, so results.all can be rendered with one loop. page.title is set from the translation html.titles.search.

Channel entries are searched in channels that have a public page (a published, unprotected channel template page), or in the channels listed in ?type=. Only entries with a URL are returned.

<div class="search-page">
  <h1>{{ page.title }}</h1>

  {% if params.q == blank %}
    <p>Type something to search for.</p>
  {% elsif results.any? %}
    <p>{{ results.all.size }} results for "{{ params.q | escape }}"</p>

    {% if results.products? %}
      <h2>Products</h2>
      {% for product in results.products %}
        {% include 'product-card', product: product %}
      {% endfor %}
    {% endif %}

    {% if results.pages? or results.articles? or results.db? %}
      <h2>Pages and articles</h2>
      <ul>
        {% for result in results.pages %}
          <li><a href="{{ result.url }}">{{ result.title }}</a></li>
        {% endfor %}
        {% for result in results.articles %}
          <li><a href="{{ result.url }}">{{ result.title }}</a></li>
        {% endfor %}
        {% for result in results.db %}
          <li><a href="{{ result.url }}">{{ result.title }}</a></li>
        {% endfor %}
      </ul>
    {% endif %}
  {% else %}
    <p>No results for "{{ params.q | escape }}".</p>
  {% endif %}
</div>

When q is empty, the template is rendered with an empty results hash.

{% search %}

{% search <scope>, query: <query>[, channel: <slug>, page: <number>, limit: <number>] %}
  ...
{% endsearch %}
  • scope (required, unquoted): all, pages, articles, products, channels or customers.
  • query (required): a variable such as params.q, or a quoted string.
  • channel: channel slug for the channels scope. Without it, all channels with search enabled are searched.
  • page, limit: which page of results to fetch and how many. Default page size is 30 (25 for channels).

Inside the block, results holds the matching products, pages, articles, channel entries or customers as regular drops. An invalid scope or a missing query: is a Liquid syntax error; an empty query or a failing search gives an empty results.

{% if params.q != blank %}
  {% search products, query: params.q, page: params.page, limit: 24 %}
    {% paginate results by 24 %}
      <div class="product-grid">
        {% for product in paginate.collection %}
          {% include 'product-card', product: product %}
        {% else %}
          <p>No products found.</p>
        {% endfor %}
      </div>
      {{ paginate | default_pagination }}
    {% endpaginate %}
  {% endsearch %}
{% endif %}

For a single scope, {% paginate %} uses the page numbers of the search itself, so pass page: params.page and use the same number for limit: and by.

Search within one channel:

{% search channels, channel: 'recipes', query: params.q, limit: 10 %}
  {% for recipe in results %}
    <a href="{{ recipe.url }}">{{ recipe.title }}</a>
  {% endfor %}
{% endsearch %}

The customers scope returns customer records. Never render it on a page that is visible to other visitors.

How matching works

  • Every word must match. Words of at least 2 letters or digits also match as part of a longer word (shoe finds snowshoes), for the first 6 such words in the query. Longer queries still work, but later words only match whole words.
  • Channel entries are only indexed when Search Enabled is on for the channel (the default). Turning it off removes the channel's entries from the index.
  • Put words in double quotes ("red wine") to search for that exact phrase. Partial-word matching does not apply then.
  • Queries that look like SQL-injection payloads return no results without searching.
  • GET /search is rate limited to 30 requests per minute per IP address, including locale-prefixed paths such as /nl/search. Don't fire a request on every keystroke; debounce live search.

Customer Login with External Providers

{% login_with %}, {% unlink_from %} and {% oauth2_consent_form %} let customers sign in with Microsoft, Facebook, SAML, another Nimbu site or Twitter, and render the consent screen when your site is an OpenID Connect provider. See Customer Login with External Providers.

{% login_with provider: 'microsoft', text: 'Sign in with Microsoft', button_class: 'btn' %}

Analytics Integration

Add Google Analytics (GA4, optionally through Google Tag Manager), Matomo or Plausible under Settings → Push & Integrations. Nimbu injects their scripts into every page itself, before </head> and </body>, and can tie them to a consent application of the consent manager. You don't need any Liquid for this.

google_analytics_tag

Outputs the GA4 gtag.js snippet for a measurement ID. Pass consent: to load it only after the visitor accepts that consent application. The filter outputs nothing when an analytics integration is enabled, so the two never double-count.

{{ 'G-XXXXXXXXXX' | google_analytics_tag }}

{{ 'G-XXXXXXXXXX' | google_analytics_tag: consent: 'google-analytics' }}

{% google_analytics 'UA-XXXXX-Y' %} and the google_analytics_ecommerce_code filter still exist for old Universal Analytics (ga.js) setups, which Google no longer processes. There is no Google Tag Manager filter; use the Google Analytics integration with Use Google Tagmanager instead.

Custom Event Tracking

<button
  onclick="gtag('event', 'add_to_cart', {
    'event_category': 'ecommerce',
    'event_label': {{ product.name | json }},
    'value': {{ product.price }}
  })">
  Add to Cart
</button>

Payment Integration

Payment providers such as Stripe and Mollie are set up as payment methods in the Nimbu admin, not in the theme. On the checkout page, payment_form renders the form for the active payment method, or a selection form when several are active:

{{ cart | payment_form }}

{{ cart | payment_form: class: 'checkout-form', button_class: 'btn-checkout' }}

See Payment Form.

Legacy payment tags

Two older tags are still available:

  • {% stripe_checkout amount %} renders the legacy Stripe Checkout (checkout.js) button for the site's Stripe payment method. Extra options become data- attributes, for example {% stripe_checkout cart.total, description: 'Order' %}.
  • {% paypal_button business: '...', item_name: '...', amount: '...', paypalCertificate: ..., certificateID: ..., merchantCertificate: ..., merchantKey: ... %}...{% endpaypal_button %} renders an encrypted PayPal Payments Standard button. It takes PayPal's HTML variables as options and renders nothing unless all four certificate options are given. The block content replaces the default submit button.

Security Features

Spam protection: reCAPTCHA and Turnstile

Enable Google reCAPTCHA or Cloudflare Turnstile under Settings → Push & Integrations and pick the Protected Forms there. The site key comes from the integration, so the tags don't take one:

{% form channels.contact %}
  ...
  {% recaptcha_tag %}
  {% submit_tag 'Send' %}
{% endform %}

<!-- Invisible reCAPTCHA: renders the submit button itself -->
{% form channels.contact %}
  ...
  {% recaptcha_button text: 'Send message', class: 'btn btn-primary' %}
{% endform %}
  • {% recaptcha_tag %} renders the challenge widget. Options are passed to the widget, for example theme: 'dark'.
  • {% recaptcha_button %} renders an invisible-reCAPTCHA submit button. Options include text: and class:.
  • The reCAPTCHA script is added to the page automatically; pass skip_install_script: true if you load it yourself.
  • When Turnstile is enabled, both tags render Turnstile instead (Turnstile also has its own {% turnstile_tag %} and {% turnstile_button %}). Without an enabled integration they output only an HTML comment.

config.recaptcha, config.turnstile and config.form_challenge tell you in Liquid which provider is active.

CSRF Protection

{% form %}, {% login_with %} and the other form tags include the CSRF token automatically:

{% form channels.contact %}
  <!-- CSRF token included -->
{% endform %}

For a hand-written form, add the token from the auth_token variable:

<form method="post" action="/cart/coupons">
  <input type="hidden" name="authenticity_token" value="{{ auth_token }}">
  <!-- form fields -->
</form>

Renders the cookie consent banner. The purposes, applications (with their cookies) and privacy policy link are configured in the Nimbu admin under Settings → Consent; the tag loads the consent manager script with that configuration:

{% consent_manager %}

Optional settings:

{% consent_manager version: 'latest', theme: 'light', must_consent: 'true', accept_all: 'true', hide_decline_all: 'false' %}
OptionDescription
versionConsent manager version: 1.0.4, 1.1.2, 1.1.3, 1.1.4, 2.0.0 or latest (currently 1.1.4). Sites created before July 2021 default to 1.0.4.
style_prefixUse your own CSS instead of the built-in styles.
theme, must_consent, accept_all, embedded, group_by_purpose, cookie_expires_after_days, hide_decline_all, hide_learn_more, notice_as_modal, additional_classPassed to the consent manager as data- attributes.
configYour own JSON configuration instead of the one generated from the admin settings.

consent_manager.consented_apps lists the applications the visitor accepted:

{% if consent_manager.consented_apps contains 'youtube' %}
  <iframe src="https://www.youtube-nocookie.com/embed/..."></iframe>
{% endif %}

javascript_tag with consent: outputs an opt-in script that the consent manager only runs after the visitor accepts that application:

{{ 'analytics.js' | javascript_tag: consent: 'analytics' }}

Practical Examples

Contact Form with Spam Protection

<div class="contact-section">
  <h2>Contact Us</h2>

  {% form channels.contact, class: 'contact-form' %}
    {% error_messages_for form_model %}

    <div class="form-row">
      <div class="col-md-6">
        {% input 'first_name', label: 'First Name', required: true %}
      </div>
      <div class="col-md-6">
        {% input 'last_name', label: 'Last Name', required: true %}
      </div>
    </div>

    <div class="form-group">
      {% input 'email', as: 'email', label: 'Email', required: true %}
    </div>

    <div class="form-group">
      {% text_area 'message', rows: 6, label: 'Message', required: true %}
    </div>

    <div class="form-group">
      {% recaptcha_tag %}
    </div>

    {% submit_tag 'Send Message', class: 'btn btn-primary' %}
  {% endform %}
</div>

Best Practices

1. Keep Secrets Out of Templates

Put keys you need in the browser (publishable keys, public IDs) under Settings → Variables and read them with site.env:

<script>window.MAPS_KEY = {{ site.env.MAPS_PUBLIC_KEY | json }};</script>

Everything a template prints is public, including site.env values of variables marked secret. Never print secret API keys.

2. HTTPS for OAuth

All OAuth flows require HTTPS in production.

3. Escape Search Input

The search query comes straight from the URL. Liquid does not escape output, so escape it wherever you print it:

<!-- ✅ Good -->
<p>Results for "{{ params.q | escape }}"</p>

<!-- ❌ Bad: reflected XSS -->
<p>Results for "{{ params.q }}"</p>
<!-- ✅ Good: Respect consent -->
{{ 'analytics.js' | javascript_tag: consent: 'analytics' }}

<!-- ❌ Bad: Track without consent -->
{{ 'analytics.js' | javascript_tag }}

Next Steps

Build advanced features with Nimbu!

On this page