The GDPR + EU AI Act + RFC 8058 checklist I implemented for a creator SaaS, with the actual code, the actual articles, and the IP-hash analytics that need no cookie banner.
I'm Jesiel — French resident, French SIRET, building Linkette as Parallactic AI (SIRET 942 662 552 00010). Linkette is a link-in-bio for creators. It holds personal data: emails, page slugs, IP-derived analytics, AI usage logs. Every line of code that touches user data has to either be GDPR-defensible or come out.
This is the actual checklist I worked through, with the actual code. Not "consult a lawyer" — I did consult one, and this is what came out the other end.
Article 13 — controller identity disclosure
GDPR Art. 13 requires the data controller's identity, contact, and (in France) the SIRET on every page that collects personal data. For us that's anywhere with a form or an account context.
The privacy page exposes:
- Controller: Parallactic AI (Jesiel Rombley, micro-entreprise)
- SIRET: 94266255200010
- Address: Paris, France
- DPO contact:
dpo@linkette.eu(yes, you have to expose a real address) - CNIL right of complaint: linked
If you're EU-incorporated, the SIRET (or local registration number) is not optional. Skipping it is the single most common compliance miss I see on European SaaS landing pages.
Article 21 — the right to object to AI processing
Article 21 gives users an unconditional opt-out from processing for direct marketing, and a qualified opt-out for "legitimate interest" processing. The Mistral-generated weekly brief is the latter: I argue it's legitimate interest because the user explicitly subscribed to a product whose value prop is "AI reads your week." But the user can object.
The schema:
-- supabase/migrations/20260517150108_settings_billing_preferences.sql
-- AI opt-out — GDPR Article 21 (right to object). Default ON; toggle off
-- disables the Mistral weekly brief on /api/ai/weekly-brief + cron loop.
alter table public.pages
add column if not exists ai_weekly_brief_enabled boolean not null default true;
The endpoint:
// apps/web/app/api/ai/weekly-brief/route.ts (excerpt)
const page = await getMyPage();
if (!page) return NextResponse.json({ error: "No page" }, { status: 404 });
// GDPR Art. 21 — respect the user's AI opt-out preference.
if ((page as { ai_weekly_brief_enabled?: boolean }).ai_weekly_brief_enabled === false) {
return NextResponse.json({
brief: "AI brief disabled in Settings. Your stats below are computed deterministically with no model involvement.",
surprise: "",
});
}
And, crucially, the cron must respect the same toggle. The most common GDPR bug I've seen is "we built an opt-out in the UI but the cron still sends." The cron filters in SQL:
// apps/web/app/api/cron/weekly-digest/route.ts (excerpt)
const { data: pages } = await supabase
.from("pages")
.select("id, user_id, slug, locale, founding_member_no, atelier_status, atelier_renews_at")
.not("published_at", "is", null)
.eq("ai_weekly_brief_enabled", true) // ← Art. 21 enforced at query
.or("founding_member_no.not.is.null,atelier_status.eq.active")
.range(offset, offset + PAGE_BATCH - 1);
The opt-out and the endpoint check and the cron check must use the same column. There is no clever compromise.
Article 18 — the right to restriction
Article 18 lets users pause processing without deleting their data. Linkette implements this as the "Unpublish" action: the page goes private, but the data persists for re-publishing later. Importantly, paused pages do not log analytics events:
// public page handler — short version
if (!page.published_at) {
// 404 to the public, page exists for the owner. No view/click events logged.
return notFound();
}
// ... only past this point do we await recordEvent({ pageId: page.id, kind: "view" });
The cron also filters on published_at, so a paused page receives zero weekly briefs. This is Art. 18 in three lines of code.
Article 28 — the sub-processor list
Article 28 requires the controller (us) to keep an up-to-date list of sub-processors and to give users notice before adding new ones. Linkette's DPA page lists, by name and jurisdiction:
| Sub-processor | Role | Jurisdiction | Data |
|---|---|---|---|
| Supabase | Database + auth | Paris (EU) | account, page, links, analytics |
| Mistral AI | LLM inference | Paris (EU) | onboarding & analytics prompts (not stored beyond inference) |
| Brevo | Transactional email | Paris (EU) | email address, message content |
| Bunny.net | CDN + storage | Slovenia (EU) | avatar uploads, public asset cache |
| Mollie | Payments | Amsterdam (EU) | billing address, payment method |
| Scaleway | VPS + DNS | Paris (EU) | request logs (7-day retention) |
| Langfuse | LLM tracing | Frankfurt (EU) | prompt + completion text, redacted |
Zero US sub-processors. The /privacy page says it; the code enforces it (see the Langfuse host check from the Mistral article).
Article 33 — 72-hour breach notification
Art. 33 is operational, not code. The pledge on the privacy page: any confirmed personal-data breach is reported to the CNIL within 72 hours and to affected users without undue delay. The "without undue delay" wording is the GDPR text; I keep it verbatim because lawyers like that.
What this requires in practice:
- A
security@linkette.euaddress that goes to a phone-pingable inbox. - An incident runbook (private repo) with the CNIL portal URL, a draft notification template, and the timeline.
- Knowing what tables hold personal data — for Linkette that's
auth.users,pages.slug,pages.bio,events.ip_hash,events.country|region|city, andai_usage.user_id. The list lives in the runbook so we don't reverse-engineer it under pressure.
EU AI Act Article 50 — AI transparency
The AI Act Art. 50 (in force since Aug 2026) requires that AI-generated content shown to users be marked as AI-generated. Linkette's solution: a tiny ✦ glyph next to every Mistral output, with consistent copy in the settings page:
// apps/web/app/(authed)/app/settings/page.tsx (excerpt)
All AI output is marked with a <span className="text-accent">
✦ Written by Mistral
</span> tag (AI Act Art. 50). All decisions stay yours — you can edit,
ignore, or regenerate anything. No profile, no scoring, no automated
decision-making (Art. 22 GDPR).
✦ was deliberate: not an emoji (no rendering inconsistency across email clients), high-contrast, semantically distinct. The weekly digest email also carries it next to the brief text and in the footer ("Written by Mistral · Sent by Linkette").
Cookie-free analytics: the actual code
This is where most "GDPR-friendly" SaaSes wave their hands. Here's the implementation: IP + salt → SHA-256 → first 24 hex chars. Salt = pageId|UTC-date|server-pepper, so the same IP counts as one unique-per-day per page, and the pepper invalidates the salt daily.
// apps/web/lib/analytics/track.ts
import { createHash } from "node:crypto";
/**
* Salted IP hash. Salt = page_id + UTC date + server pepper. Rotates
* daily so we count unique-per-day without storing IPs across days.
* Returns null when no IP is present (local dev, headless calls).
*
* The server pepper (IP_HASH_PEPPER env var) is the load-bearing piece:
* without it, an attacker who knows the slug + day can brute-force the
* hash against a list of likely IPs in seconds. With it, they'd need to
* extract the env var from the running server first.
*/
export function hashIpForDay(ip: string | null, pageId: string, now: Date = new Date()): string | null {
if (!ip) return null;
const day = now.toISOString().slice(0, 10); // YYYY-MM-DD UTC
const pepper = process.env.IP_HASH_PEPPER ?? "";
if (!pepper && process.env.NODE_ENV !== "test") {
if (!warnedAboutMissingPepper) {
console.warn("[analytics] IP_HASH_PEPPER not set — IP-hash unique counts are brute-forceable.");
warnedAboutMissingPepper = true;
}
}
const h = createHash("sha256");
h.update(`${ip}|${pageId}|${day}|${pepper}`);
return h.digest("hex").slice(0, 24);
}
Why the pepper matters: the CNIL has been explicit since 2020 that a salted hash of an IP, where the salt is guessable, is still personal data because it can be brute-forced. With a server-side pepper that never touches client-accessible state, the hash becomes a true pseudonym. The pepper rotates out-of-band; rotating it costs you one day of inflated unique counts (cheap), and protects you against a stolen-database scenario.
The same module deliberately drops bots, so the metric isn't polluted by Slackbot link-unfurls:
export function classifyUserAgent(ua: string | null): UaClass | null {
if (!ua) return null;
const s = ua.toLowerCase();
if (/bot|crawler|spider|crawling|preview|facebookexternalhit|slackbot|whatsapp|telegrambot|discordbot|linkedinbot|twitterbot|googlebot|bingbot|duckduckbot/.test(s)) {
return "bot";
}
// ...
}
// later, in recordEvent:
if (uaClass === "bot") return;
Because we store no cookie and no cross-site identifier, Linkette ships without a cookie banner. The CNIL has confirmed in multiple decisions that cookie-free, IP-anonymized server-side analytics fall outside the ePrivacy consent requirement.
RFC 8058 — true one-click unsubscribe
Gmail and Apple Mail surface a native "Unsubscribe" affordance only if the mail carries both List-Unsubscribe and List-Unsubscribe-Post: List-Unsubscribe=One-Click, and the POST endpoint completes without further user interaction. HMAC-signed tokens make this safe:
// apps/web/lib/email/unsubscribe.ts
import { createHmac, timingSafeEqual } from "node:crypto";
function secret(): string {
const s = process.env.UNSUBSCRIBE_SECRET ?? process.env.CRON_SECRET;
if (!s) throw new Error("UNSUBSCRIBE_SECRET / CRON_SECRET not set");
return s;
}
export function signUnsubscribe(userId: string): string {
return createHmac("sha256", secret()).update(userId).digest("hex");
}
export function verifyUnsubscribe(userId: string, token: string): boolean {
try {
const expected = signUnsubscribe(userId);
const a = Buffer.from(expected, "hex");
const b = Buffer.from(token, "hex");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
} catch {
return false;
}
}
/** POST endpoint for RFC 8058 one-click — used in the List-Unsubscribe
* header. Must be a different path from the page so Next can serve both
* (`page.tsx` and `route.ts` can't coexist at the same segment). */
export function unsubscribePostUrl(siteUrl: string, userId: string): string {
return `${siteUrl.replace(/\/$/, "")}/api/unsubscribe?u=${encodeURIComponent(userId)}&t=${signUnsubscribe(userId)}`;
}
The Brevo send helper attaches both headers when an unsubscribe URL is provided:
// apps/web/lib/email/brevo.ts (excerpt)
headers: input.unsubscribeUrl
? {
"List-Unsubscribe": `<${input.unsubscribeUrl}>`,
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
}
: undefined,
The cron emits the POST URL for the header, the GET URL for the visible footer link, signed with the same HMAC:
const unsub = unsubscribeUrl(siteUrl, page.user_id); // GET /unsubscribe (page)
const unsubPost = unsubscribePostUrl(siteUrl, page.user_id); // POST /api/unsubscribe (header)
timingSafeEqual is doing real work here — token comparison is the kind of thing where a naive === leaks bits via response time. Use node:crypto.
Things I tried that didn't work
Plausible Analytics for the dashboard
I considered Plausible for the creator-facing analytics dashboard. Self-hostable, GDPR-friendly, EU vendor — sounded perfect. Two problems: (1) Plausible runs per-site, and Linkette's analytics are per-page (one page per user), so the multi-tenancy story is awkward; (2) we wanted the data in Postgres anyway, to feed the Mistral weekly brief. We collapsed it into a single events table with a SQL function that returns the summary as JSON.
Storing the raw IP for "fraud detection"
Tempted in week 2. The argument: "we'll need it eventually for spam." Reality: we've never needed it. The hashed-IP-per-day count plus the bot filter has been enough for every spam/abuse incident in 8 weeks. Don't collect data you can't justify; you'll just have to delete it later under Art. 17.
Honoring "Do Not Track"
I added it, then removed it. DNT is essentially dead — most browsers don't ship it as default, and CNIL guidance is that consent must be explicit and informed, not browser-signal-driven. Cookie-free analytics + no behavioral tracking is the better story, and the only place DNT remains useful is for marketing pixels we don't have.