Biscuit
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.
Requirements
| 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).
AI-assisted setup (Claude Code)
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
Manual installation
Add to your Gemfile:
gem "biscuit-rails"Then:
bundle installSetup
1. Mount the engine
In config/routes.rb:
Rails.application.routes.draw do
mount Biscuit::Engine, at: "/biscuit"
# ... your other routes
end2. Register the Stimulus controller
In app/javascript/controllers/index.js:
import BiscuitController from "biscuit/biscuit_controller"
application.register("biscuit", BiscuitController)3. Include the stylesheet
In your layout (app/views/layouts/application.html.erb):
<%= stylesheet_link_tag "biscuit/biscuit" %>4. Render the banner
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.
Banner Options
biscuit_banner accepts keyword options to control behaviour per-page:
reload_on_consent
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.
Configuration
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"
endAll options at a glance
| 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 |
Custom Cookie Categories
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.
CSS Theming
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.
Available custom properties
| 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 |
Override example
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);
}Checking Consent in Views
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.
Checking Consent in Controllers
class ApplicationController < ActionController::Base
def analytics_enabled?
Biscuit::Consent.new(cookies).allowed?(:analytics)
end
endCookie Format
The 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.
GDPR Notes
Biscuit provides the consent UI and storage mechanism. You are responsible for:
What Biscuit does
- 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
What Biscuit does NOT do
-
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.
Pattern: blocking non-essential scripts until consent
<%# 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.)
GDPR compliance checklist
- 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
i18n
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."Engine Routes
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).
License
MIT