Cookie consent for React Router and Remix

In framework mode, root.tsx renders the document, so the tag goes in its Layout, first in <head>. It is server-rendered, so it runs before hydration and before any tracker. Consents your forms ask for are recorded from an action, on the server.

Set it up with your AI agent

Connect the Croombit MCP server to your editor or coding agent and paste this. It creates the site, the cookie categories and the banner, and edits your code.

Using Croombit: create a site for this project and keep the cookie categories it comes with. Write the banner text and publish the banner. Create a TEST publishable key for localhost:3000. This is a React Router v7 framework-mode app: add the cmp.js <script> first in <head> of the Layout in app/root.tsx, with the key from VITE_CROOMBIT_KEY, and record the newsletter checkbox of our signup action with @croombit/sdk and CROOMBIT_SECRET_KEY.

The agent works in TEST. Going live takes two steps in the console: verify your domain and swap in a LIVE key — see going live.

Or by hand

1. Add the tag to root.tsx

Put the key in .env as VITE_CROOMBIT_KEY. A Remix v2 app has the same Layout export in app/root.tsx.

// app/root.tsx
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';

export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        {/* Server-rendered first in <head>, so it runs before any tracker */}
        <script src="https://croombit.com/cmp.js" data-tc-api-key={import.meta.env.VITE_CROOMBIT_KEY} />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

export default function App() {
  return <Outlet />;
}

2. Hold back trackers

Render tracker tags with type="text/plain" and data-tc-consent. Tags inserted at runtime from well-known hosts are held back automatically.

<script type="text/plain" data-tc-consent="analytics" async
        src="https://www.googletagmanager.com/gtag/js?id=G-XXXX"></script>
<script type="text/plain" data-tc-consent="analytics">
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());
  gtag('config', 'G-XXXX');
</script>

3. Record consents from an action

With a secret key, on the server. Each decision lands in the append-only vault with a signed receipt — see Record and query consent.

// app/routes/signup.tsx — server side, with a secret key
import { CroombitClient } from '@croombit/sdk';
import type { Route } from './+types/signup';

const tc = new CroombitClient({ baseUrl: 'https://api.croombit.com', apiKey: process.env.CROOMBIT_SECRET_KEY! });

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const email = String(form.get('email'));
  // …create the account, then:
  await tc.recordConsent(
    { subject: 'email:' + email, purpose: 'newsletter', state: form.get('newsletter') ? 'GRANTED' : 'DENIED', channel: 'signup_form' },
    { idempotencyKey: crypto.randomUUID() },
  );
}
<a href="#" data-tc-open-preferences>Cookie settings</a>

Questions

Does it work in SPA mode?

Yes. With ssr: false the same Layout is prerendered into index.html, tag included.

Does hydration run the script twice?

No. The browser runs it once while parsing the server HTML; React hydrates the element without re-executing it.