GDPR-compliant cookie consent banner for Rails 8. Renders a configurable bottom/top banner, manages consent state via a browser cookie, and exposes a Stimulus controller for interactivity. Supports i18n and CSS custom property theming with no external runtime dependencies.
| Requirement | Version |
|---|---|
| Ruby | >= 3.2 |
| Rails | >= 8.0 |
| Stimulus | Any (via @hotwired/stimulus) |
| Import maps | Rails default (importmap-rails) |
No Sprockets, no build step. Assets are served via Propshaft (Rails 8 default).
If you use Claude Code, the quickest way to install and configure biscuit is via the built-in setup skill.
After adding the gem to your Gemfile and running bundle install, run the
generator to install the skill:
rails generate biscuit:installThen open Claude Code in your project and run:
/biscuit-install
The skill will:
- Check your app is compatible (Ruby, Rails, Propshaft, Stimulus)
- Mount the engine and register the Stimulus controller
- Ask about banner position, cookie categories, and other preferences
- Generate
config/initializers/biscuit.rbfrom your answers - Scan your codebase for existing cookies and third-party tracking scripts
(Google Analytics, GTM, Meta Pixel, etc.) and help you wrap them with
biscuit_allowed?guards - Add integration tests and run them
- Optionally commit everything
Add to your Gemfile:
gem "biscuit-rails"Then:
bundle installIn config/routes.rb:
Rails.application.routes.draw do
mount Biscuit::Engine, at: "/biscuit"
# ... your other routes
endIn app/javascript/controllers/index.js:
import BiscuitController from "biscuit/biscuit_controller"
application.register("biscuit", BiscuitController)In your layout (app/views/layouts/application.html.erb):
<%= stylesheet_link_tag "biscuit/biscuit" %>In your layout, inside <body>:
<%= biscuit_banner %>That's it. The banner renders on every page. Once a user makes a consent choice it hides itself and shows a small "Cookie settings" link so they can revisit their preferences at any time.
biscuit_banner accepts keyword options to control behaviour per-page:
When true, the page reloads via Turbo.visit after the user saves their
consent choice, instead of just hiding the banner. This is useful when your
layout conditionally loads scripts based on consent — a reload ensures those
scripts are evaluated with the new cookie in place.
<%= biscuit_banner(reload_on_consent: true) %>Default: false — the banner hides in place without a page reload.
Create an initializer at config/initializers/biscuit.rb:
Biscuit.configure do |config|
# Cookie categories — see "Custom categories" below
config.categories = {
necessary: { required: true },
analytics: { required: false },
preferences: { required: false },
marketing: { required: false }
}
# Name of the browser cookie that stores consent state
# Default: "biscuit_consent"
config.cookie_name = "biscuit_consent"
# How long the consent cookie lasts, in days
# Default: 365
config.cookie_expires_days = 365
# Cookie path
# Default: "/"
config.cookie_path = "/"
# Cookie domain — nil means current domain
# Default: nil
config.cookie_domain = nil
# SameSite attribute
# Default: "Lax"
config.cookie_same_site = "Lax"
# Banner position: :bottom or :top
# Default: :bottom
config.position = :bottom
# URL for the "Learn more" privacy policy link
# Default: "#"
config.privacy_policy_url = "/privacy"
end| Option | Default | Description |
|---|---|---|
categories |
{necessary: {required: true}, analytics: {required: false}, marketing: {required: false}} |
Cookie categories shown to the user |
cookie_name |
"biscuit_consent" |
Browser cookie name |
cookie_expires_days |
365 |
Cookie lifetime in days |
cookie_path |
"/" |
Cookie path |
cookie_domain |
nil |
Cookie domain (nil = current domain) |
cookie_same_site |
"Lax" |
SameSite cookie attribute |
position |
:bottom |
Banner position (:bottom or :top) |
privacy_policy_url |
"#" |
"Learn more" link URL |
Define any categories you need. Each entry requires a :required key.
Categories with required: true are shown as permanently checked and
non-toggleable (necessary cookies). All others are opt-in checkboxes.
config.categories = {
necessary: { required: true },
analytics: { required: false },
preferences: { required: false },
marketing: { required: false }
}Add matching i18n keys for each custom category. For example, to add a
preferences category, add to config/locales/en.yml:
en:
biscuit:
categories:
preferences:
name: "Preferences"
description: "Remember your settings and personalisation choices."Biscuit ships with built-in translations for necessary, analytics,
marketing, and preferences in English and French. Any other category
requires you to add your own keys.
All styles are scoped under .biscuit-banner. Every visual property is
expressed as a CSS custom property, so you can override the entire look
without touching the gem.
| Property | Default | Description |
|---|---|---|
--biscuit-bg |
Canvas |
Banner background colour (browser default background) |
--biscuit-color |
CanvasText |
Banner text colour (browser default text) |
--biscuit-muted |
GrayText |
Secondary / description text colour |
--biscuit-accent |
#4f46e5 |
Primary button background |
--biscuit-accent-hover |
#4338ca |
Primary button hover background |
--biscuit-border |
rgba(0,0,0,0.12) |
Divider / border colour |
--biscuit-radius |
0.375rem |
Button / panel border radius |
--biscuit-font-size |
0.875rem |
Base font size |
--biscuit-font-family |
inherit |
Font family |
--biscuit-z-index |
9999 |
Stack order |
--biscuit-padding |
1rem 1.5rem |
Banner padding |
--biscuit-shadow-bottom |
0 -2px 12px rgba(0,0,0,0.12) |
Shadow when position: bottom |
--biscuit-shadow-top |
0 2px 12px rgba(0,0,0,0.12) |
Shadow when position: top |
--biscuit-max-width |
64rem |
Inner content max-width |
In your application's CSS, after including the biscuit stylesheet:
.biscuit-banner {
--biscuit-accent: #0070f3;
--biscuit-accent-hover: #005bb5;
--biscuit-border: rgba(0, 0, 0, 0.08);
}Use the biscuit_allowed? helper, which is available in all views and
layouts:
<% if biscuit_allowed?(:analytics) %>
<!-- Google Analytics or similar -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXX"></script>
<% end %>
<% if biscuit_allowed?(:marketing) %>
<!-- Marketing pixel -->
<% end %>:necessary always returns true regardless of cookie state.
class ApplicationController < ActionController::Base
def analytics_enabled?
Biscuit::Consent.new(cookies).allowed?(:analytics)
end
endThe consent cookie stores a JSON payload:
{
"v": 1,
"consented_at": "2026-03-19T10:00:00Z",
"categories": {
"necessary": true,
"analytics": false,
"marketing": true
}
}v— schema version (currently1). Biscuit ignores cookies from unknown versions.consented_at— UTC ISO 8601 timestamp of when consent was recorded.categories— per-category boolean map.necessaryis alwaystrue.
The cookie is not httponly so that client-side JavaScript can read
consent state for lazy-loading scripts.
Biscuit provides the consent UI and storage mechanism. You are responsible for:
- Renders a banner that requires an explicit user action before dismissal (no auto-dismiss)
- Offers equally prominent "Accept all" and "Reject non-essential" buttons
- Records granular, timestamped consent per category
- Allows the user to withdraw or amend consent at any time via the "Cookie settings" link
- Marks
:necessarycookies as non-toggleable and clearly labelled - Writes no non-essential cookies itself — only the consent cookie, which is a functional/necessary cookie
- It does not block third-party scripts automatically. You must
conditionally load scripts based on
biscuit_allowed?(:category). See the pattern below. - It does not implement geo-targeting (showing the banner only to EU visitors).
- It does not store consent in a database (v1 is cookie-only).
- It does not provide a legal opinion on whether your implementation meets GDPR requirements. Consult a lawyer.
<%# In your layout — only load analytics after consent %>
<% if biscuit_allowed?(:analytics) %>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXX');
</script>
<% end %>For scripts that must load on the client side after a user accepts consent during their current session (without a page reload), listen for the Fetch response in your own JavaScript and initialise scripts there, or use a lightweight Turbo visit to reload the page after the consent POST succeeds. (Turbo Stream support for post-consent injection is planned for v2.)
- No non-essential cookies set before consent
- Consent is freely given — equal prominence for accept and reject
- No pre-ticked boxes for non-required categories
- No dark patterns
- User can withdraw or amend consent at any time
- Consent is granular — recorded per category
- Consent is timestamped
- Necessary cookies are clearly labelled and non-toggleable
- Banner does not auto-dismiss
Biscuit ships with translations for English (en), French (fr),
German (de), and Spanish (es). To add another locale, create
config/locales/biscuit.<locale>.yml in your app:
pt:
biscuit:
banner:
aria_label: "Consentimento de cookies"
message: "Utilizamos cookies para melhorar a sua experiência neste site."
learn_more: "Saber mais"
accept_all: "Aceitar tudo"
reject_all: "Rejeitar não essenciais"
manage: "Gerir preferências"
save: "Guardar preferências"
reopen: "Definições de cookies"
categories:
necessary:
name: "Necessários"
description: "Indispensáveis para o funcionamento do site. Não podem ser desativados."
analytics:
name: "Análise"
description: "Ajudam-nos a perceber como os visitantes utilizam o site."
marketing:
name: "Marketing"
description: "Utilizados para mostrar publicidade personalizada."
preferences:
name: "Preferências"
description: "Memorizam as suas definições e escolhas de personalização."The engine mounts two endpoints:
| Method | Path | Action |
|---|---|---|
POST |
/biscuit/consent |
Record consent for all categories |
DELETE |
/biscuit/consent |
Clear the consent cookie |
Both endpoints require a valid CSRF token. The Stimulus controller
reads the token from the data-biscuit-csrf-token-value attribute
(set automatically by the banner partial from form_authenticity_token).
MIT