TLDR: Add GDPR-compliant cookie consent to Remix in 2 minutes. One script tag in root.tsx. Works with every deployment target. Full Google Consent Mode v2.
Read full summary
Remix embraces web standards. Cookie consent should too. One script in root.tsx gives you full GDPR compliance. This guide covers the basics, custom hooks, and patterns for every Remix deployment target.
Summary by Claude AI
Web Standards, Applied to Consent
Remix has opinions. Web standards are good. Progressive enhancement matters. Complexity should be avoided.
Traditional consent tools ignore these principles. They want npm packages, complex configuration, and client-only solutions that break without JavaScript.
GetCookies follows Remix's philosophy: use web standards, keep it simple, let it work.
The 2-Minute Setup
// app/root.tsx
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react"
export default function App() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
<script
src="https://getcookies.co/api/v1/widget/loader.js"
data-domain-id="YOUR_DOMAIN_ID"
async
/>
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
</body>
</html>
)
}
That's it. Refresh your site. Cookie banner appears.
You now have:
- GDPR-compliant consent collection
- Google Consent Mode v2 (all four signals)
- Automatic script blocking
- Third-party pixel consent
No npm install. No loader configuration. No complexity.
Environment Variables with Loaders
Use Remix loaders to pass environment variables:
// app/root.tsx
import { json } from "@remix-run/node"
import { useLoaderData } from "@remix-run/react"
export async function loader() {
return json({
ENV: {
GETCOOKIES_DOMAIN_ID: process.env.GETCOOKIES_DOMAIN_ID,
},
})
}
export default function App() {
const { ENV } = useLoaderData<typeof loader>()
return (
<html lang="en">
<head>
<Meta />
<Links />
{ENV.GETCOOKIES_DOMAIN_ID && (
<script
src="https://getcookies.co/api/v1/widget/loader.js"
data-domain-id={ENV.GETCOOKIES_DOMAIN_ID}
async
/>
)}
</head>
<body>
<Outlet />
<Scripts />
</body>
</html>
)
}
# .env
GETCOOKIES_DOMAIN_ID=your-domain-id
A Custom Hook for Consent
Create a reusable hook for consent state:
// app/hooks/useConsent.ts
import { useEffect, useState, useCallback } from 'react'
type ConsentCategory = 'analytics' | 'marketing' | 'preferences'
export function useConsent() {
const [consent, setConsent] = useState<Record<ConsentCategory, boolean>>({
analytics: false,
marketing: false,
preferences: false,
})
const updateConsent = useCallback(() => {
if (typeof window !== 'undefined' && window.GetCookies) {
setConsent({
analytics: window.GetCookies.hasConsent('analytics'),
marketing: window.GetCookies.hasConsent('marketing'),
preferences: window.GetCookies.hasConsent('preferences'),
})
}
}, [])
useEffect(() => {
updateConsent()
window.addEventListener('getcookies:consent', updateConsent)
return () => window.removeEventListener('getcookies:consent', updateConsent)
}, [updateConsent])
const showPreferences = useCallback(() => {
window.GetCookies?.showPreferences()
}, [])
const acceptAll = useCallback(() => {
window.GetCookies?.acceptAll()
}, [])
const rejectAll = useCallback(() => {
window.GetCookies?.rejectAll()
}, [])
return {
...consent,
showPreferences,
acceptAll,
rejectAll,
}
}
Use it in any route:
// app/routes/_index.tsx
import { useConsent } from '~/hooks/useConsent'
export default function Index() {
const { analytics, marketing, showPreferences } = useConsent()
return (
<div>
<h1>Welcome</h1>
<p>Analytics: {analytics ? 'Enabled' : 'Disabled'}</p>
<p>Marketing: {marketing ? 'Enabled' : 'Disabled'}</p>
<button onClick={showPreferences}>
Cookie Settings
</button>
</div>
)
}
Conditional Analytics Loading
Method 1: Effect-Based Loading
// app/components/Analytics.tsx
import { useEffect } from 'react'
import { useConsent } from '~/hooks/useConsent'
export function Analytics() {
const { analytics } = useConsent()
useEffect(() => {
if (analytics && typeof window !== 'undefined') {
const script = document.createElement('script')
script.src = 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXX'
script.async = true
document.head.appendChild(script)
window.dataLayer = window.dataLayer || []
function gtag(...args: any[]) {
window.dataLayer.push(args)
}
gtag('js', new Date())
gtag('config', 'G-XXXXXXXX')
}
}, [analytics])
return null
}
Add to root:
// app/root.tsx
import { Analytics } from '~/components/Analytics'
export default function App() {
return (
<html>
<head>
{/* GetCookies script */}
</head>
<body>
<Outlet />
<Analytics />
<Scripts />
</body>
</html>
)
}
Method 2: Data Attributes
Let GetCookies manage script activation:
// app/root.tsx
export default function App() {
return (
<html>
<head>
<script
src="https://getcookies.co/api/v1/widget/loader.js"
data-domain-id="YOUR_DOMAIN_ID"
async
/>
{/* Only loads after analytics consent */}
<script
type="text/plain"
data-cookieconsent="analytics"
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXX"
/>
</head>
<body>
<Outlet />
<Scripts />
</body>
</html>
)
}
Resource Routes for Consent Sync
Want to log consent server-side? Create a resource route:
// app/routes/api.consent.ts
import { json } from "@remix-run/node"
import type { ActionFunctionArgs } from "@remix-run/node"
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData()
const consentData = formData.get('consent')
if (typeof consentData === 'string') {
const consent = JSON.parse(consentData)
// Log consent for audit purposes
console.log('Consent received:', consent)
// Store in database if needed
}
return json({ success: true })
}
Sync from client:
// app/hooks/useConsentSync.ts
import { useFetcher } from '@remix-run/react'
import { useEffect } from 'react'
export function useConsentSync() {
const fetcher = useFetcher()
useEffect(() => {
const handleConsent = (e: CustomEvent) => {
fetcher.submit(
{ consent: JSON.stringify(e.detail) },
{ method: 'POST', action: '/api/consent' }
)
}
window.addEventListener('getcookies:consent', handleConsent as EventListener)
return () => {
window.removeEventListener('getcookies:consent', handleConsent as EventListener)
}
}, [fetcher])
}
TypeScript Definitions
// app/types/getcookies.d.ts
declare global {
interface Window {
GetCookies?: {
hasConsent: (category: 'analytics' | 'marketing' | 'preferences') => boolean
showPreferences: () => void
acceptAll: () => void
rejectAll: () => void
}
dataLayer: any[]
}
}
export {}
Common Patterns
Footer Component
// app/components/Footer.tsx
import { useConsent } from '~/hooks/useConsent'
export function Footer() {
const { showPreferences } = useConsent()
return (
<footer className="py-8 border-t">
<nav className="flex gap-4">
<a href="/privacy">Privacy Policy</a>
<a href="/terms">Terms of Service</a>
<button
onClick={showPreferences}
className="text-blue-600 hover:underline"
>
Cookie Settings
</button>
</nav>
</footer>
)
}
Consent-Gated Content
// app/components/PersonalizedSection.tsx
import { useConsent } from '~/hooks/useConsent'
export function PersonalizedSection() {
const { marketing } = useConsent()
if (!marketing) {
return (
<div className="p-4 bg-gray-100 rounded">
<p>Enable marketing cookies to see personalized recommendations.</p>
<button
onClick={() => window.GetCookies?.showPreferences()}
className="mt-2 text-blue-600"
>
Update Preferences
</button>
</div>
)
}
return (
<div>
{/* Personalized content */}
</div>
)
}
Deployment Targets
GetCookies works with every Remix deployment target:
Vercel
npx vercel
Cloudflare Workers
// No changes needed - GetCookies is client-side only
Node.js
npm run build
npm start
Deno
deno task build
deno task start
Error Boundary Considerations
Include GetCookies in error boundaries for consistency:
// app/root.tsx
export function ErrorBoundary() {
return (
<html>
<head>
<title>Error</title>
<script
src="https://getcookies.co/api/v1/widget/loader.js"
data-domain-id="YOUR_DOMAIN_ID"
async
/>
</head>
<body>
<h1>Something went wrong</h1>
<Scripts />
</body>
</html>
)
}
SSR Considerations
GetCookies runs client-side. Avoid hydration mismatches:
export default function ConsentAwareComponent() {
const [mounted, setMounted] = useState(false)
const { analytics } = useConsent()
useEffect(() => {
setMounted(true)
}, [])
// Consistent content during SSR
if (!mounted) {
return <GenericContent />
}
return analytics ? <PersonalizedContent /> : <GenericContent />
}
Performance Impact
| Metric | Impact |
|---|---|
| Bundle size | +~1.6 KB loader (gzipped); banner ~20 KB, loaded async |
| LCP | No impact |
| FID | No impact |
| CLS | No impact |
Your Lighthouse scores stay excellent.
What You Don't Have to Do
- Install any npm package
- Configure any loader
- Set up GTM triggers
- Read Consent Mode documentation
- Debug SSR timing issues
- Wonder if it actually works
One script tag. Full compliance. Web standards, respected.
Ready to add cookie consent to your Remix app?