Το site αυτό το έφτιαξα για τον εαυτό μου — και ακριβώς εκεί αρχίζουν τα προβλήματα. Έκοβα γωνίες που δεν θα τολμούσα σε δουλειά πελάτη. Το GA4 tag έτρεχε από την πρώτη μέρα: page views για κάθε επισκέπτη, χωρίς κανένα consent dialog. Στην ΕΕ αυτό δεν επιτρέπεται — και επειδή μένω στην Ελλάδα, το πιο προφανές μη-συμβατό site που συντηρούσα ήταν το δικό μου. Το έφτιαξα σήμερα.
Δες τι έκανα ακριβώς και πού με «τσίμπησε» στην πορεία.
Η απαίτηση: opt-in, όχι opt-out
Το άρθρο 7 του GDPR θέλει ελεύθερη, συγκεκριμένη, ενήμερη και αναμφίβολη συγκατάθεση πριν βάλεις μη-αναγκαία cookies. Τα analytics cookies — περιλαμβανομένων των _ga και _gclxxxx του GA4 — δεν είναι αναγκαία. Η προεπιλογή πρέπει να είναι άρνηση. Ο χρήστης επιλέγει να δεχτεί· δεν ζητάς συγγνώμη μετά.
Αυτό αποκλείει το γνωστό «βάζουμε το cookie, αλλά μπορείς να το απενεργοποιήσεις από το footer» που κάνει ακόμα η μισή διαδίκτυο. Τα opt-out banners δεν είναι συμβατά. Η αρχική κατάσταση πρέπει να είναι χωρίς tracking.
Γιατί Google Consent Mode v2 και όχι conditional script injection
Η προφανής λύση: μην φορτώνεις το <Script> tag του GA4 μέχρι να συμφωνήσει ο χρήστης. Δυναμική φόρτωση μετά την αποδοχή. Καθαρό και απλό.
Δεν πήγα εκεί, γιατί το GA4 tag ήταν ήδη μέσα στο app/layout.tsx και δεν ήθελα να το ξεκολλήσω. Η conditional injection φέρνει κι ένα δεύτερο πρόβλημα: αν ο χρήστης έχει ήδη συμφωνήσει από προηγούμενη επίσκεψη, πρέπει να φορτώσεις το script στο mount, να ελέγξεις το localStorage και μετά να στείλεις το page view — με αποτέλεσμα race conditions μεταξύ της hydration του Next.js και του δυναμικά φορτωμένου <script>.
Το Google Consent Mode v2 το λύνει αλλιώς. Ορίζεις analytics_storage: 'denied' πριν φορτωθεί το gtag.js. Το GA4 αρχικοποιείται κανονικά αλλά δεν γράφει cookies και δεν στέλνει αναγνωριστικά. Όταν ο χρήστης πει «ναι», καλείς gtag('consent', 'update', ...) και το tracking αρχίζει από εκεί. Ένα tag, μία αρχικοποίηση, χωρίς hydration races, χωρίς flash.
Το consent default πριν φορτωθεί το gtag.js
Το default πρέπει να τρέξει πριν από το GA4 script. Στο Next.js 15 App Router αυτό σημαίνει inline script με beforeInteractive στην κορυφή του <head>:
// app/layout.tsx
import Script from 'next/script';
<Script id="consent-default" strategy="beforeInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('consent', 'default', {
analytics_storage: 'denied',
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
wait_for_update: 500
});
`}
</Script>
Το wait_for_update: 500 λέει στο GA4 να κρατήσει το πρώτο network request για 500ms σε περίπτωση που φτάσει γρήγορα ένα consent update — χρήσιμο όταν ο χρήστης έχει ήδη συμφωνήσει και αποκαθιστάς αυτή την κατάσταση από το localStorage στο mount.
Η αρχικοποίηση του GA4 χρησιμοποιεί send_page_view: false για να μην στείλει το πρώτο page view παρά μόνο αφού δοθεί ρητή συγκατάθεση:
gtag('config', process.env.NEXT_PUBLIC_GA_ID!, { send_page_view: false });
Το banner: κατηγορίες, κουμπιά και ανάκληση
Το banner έχει δύο κατηγορίες. Τα αναγκαία cookies (session, CSRF) είναι πάντα ενεργά — το checkbox δεν αλλάζει. Τα analytics είναι απενεργοποιημένα εξ ορισμού. Τρεις επιλογές για τον χρήστη:
- Αποδοχή όλων — ενεργοποιεί τα analytics, στέλνει το αναβληθέν page view, αποθηκεύει τη συγκατάθεση.
- Απόρριψη όλων — αποθηκεύει την άρνηση, κλείνει το banner.
- Προσαρμογή → Αποθήκευση προτιμήσεων — διαβάζει τα επιμέρους toggles και ενεργοποιεί ή απενεργοποιεί ανάλογα.
Η κατάσταση αποθηκεύεται στο localStorage με το κλειδί klawsfx_cookie_consent. Στο mount, το banner ελέγχει αυτό το κλειδί· αν βρει αποθηκευμένη επιλογή, αποκαθιστά το consent μέσω gtag('consent', 'update', ...) και δεν εμφανίζεται ξανά.
Η κλήση update με το αναβληθέν page view μοιάζει ως εξής:
function grantAnalytics() {
gtag('consent', 'update', { analytics_storage: 'granted' });
gtag('config', process.env.NEXT_PUBLIC_GA_ID!, { send_page_view: true });
}
Το GDPR απαιτεί η ανάκληση να είναι εξίσου εύκολη με την παροχή της. Γι' αυτό στο footer υπάρχει σύνδεσμος «Προτιμήσεις cookies». Κλικ → διαγράφεται το klawsfx_cookie_consent από το localStorage → η σελίδα ξαναφορτώνεται → το banner εμφανίζεται από την αρχή με την προεπιλεγμένη άρνηση. Μία γραμμή JavaScript, χωρίς κόλπα.
Το κείμενο του banner από τη βάση δεδομένων
Να βάλω το κείμενο πολιτικής hardcoded σε React component μου φαινόταν λάθος. Η διατύπωση αλλάζει — και τελευταίο που θέλω είναι να σκαλίζω τον κώδικα για να αλλάξω ένα string και να κάνω redeploy.
Το banner τραβά περιγραφές κατηγοριών και κείμενο πολιτικής από ένα singleton row CookieSettings στη Postgres μέσω Prisma. Στο admin υπάρχει καρτέλα «Cookies» που μου επιτρέπει να επεξεργαστώ το κείμενο απευθείας. Η λογική consent μένει στον κώδικα· μόνο το ανθρώπινο κείμενο ζει στη βάση.
Το Vercel deploy που έσπασε αμέσως
Πρόσθεσα το μοντέλο CookieSettings στο schema.prisma, έτρεξα prisma migrate dev τοπικά, το build δούλεψε μια χαρά. Push στο main, το Vercel ξεκίνησε deploy — και έπεσε με TypeScript error σε generated τύπο Prisma που δεν υπήρχε.
Το πρόβλημα: το Vercel κάνει cache τα node_modules μεταξύ deploys. Το cached @prisma/client δεν ήξερε για το νέο μοντέλο γιατί το prisma generate δεν είχε τρέξει εκεί. Τοπικά το είχα τρέξει χειροκίνητα, οπότε δεν το πρόσεξα.
Η λύση: να μπει το prisma generate μέσα στο build:
// package.json
{
"scripts": {
"postinstall": "prisma generate",
"build": "prisma generate && next build"
}
}
Χρειάζονται και τα δύο — το postinstall για fresh installs και το ρητό prefix στο build για το cached-install path του Vercel. Με ένα μόνο δεν φτάνεις: είχα ήδη το postinstall από παλιό migration και δεν έτρεχε γιατί το Vercel παρέλειπε το install step σε cache hit.
Σημείωση για το CSP
Το consent default είναι inline script, άρα το Content-Security-Policy πρέπει να το επιτρέπει. Αν έχεις nonce-based CSP, φτιάξε nonce στο middleware και πέρασέ το στο <Script> — αυτή είναι η αυστηρότερη ρύθμιση και αυτή που θα έβαζα σε client project. Σε κάθε περίπτωση, το snippet είναι στατικό, first-party και δεν τραβά εξωτερικά δεδομένα — παραμένει μικρή, καλά ορισμένη εξαίρεση, όχι τρύπα.
Τι θα έκανα διαφορετικά σε site πελάτη
Σε εμπορικό project θα πήγαινα παραπέρα:
- Nonce-based CSP για inline scripts, που φτιάχνεται ανά request στο Next.js middleware.
- Server-side καταγραφή consent αν ο πελάτης χρειάζεται audit trail — το άρθρο 7.1 του GDPR λέει ότι πρέπει να μπορείς να αποδείξεις ότι πήρες τη συγκατάθεση.
- Πιο λεπτομερής λίστα κατηγοριών αν το site τρέχει και marketing pixels παράλληλα με analytics.
Για portfolio που τρέχει ένα analytics tag και μηδέν ads, αυτή η υλοποίηση καλύπτει τη νομική απαίτηση καθαρά.
FAQ
Αποτρέπει πραγματικά το Google Consent Mode v2 το GA4 από το tracking πριν τη συγκατάθεση;
Ναι. Με analytics_storage σε 'denied' το GA4 δεν γράφει cookies και δεν στέλνει αναγνωριστικά χρηστών. Μπορεί να στέλνει cookieless, aggregate σήματα αν έχεις ενεργοποιημένο modelling στην ιδιότητα GA4 — έλεγξε τις ρυθμίσεις αν χρειάζεσαι απολύτως μηδενικά δεδομένα.
Μπορώ να μη φορτώνω καθόλου το GA4 script μέχρι τη συγκατάθεση, αντί για Consent Mode; Μπορείς και λειτουργεί. Το κόστος είναι πολυπλοκότητα: πρέπει να χειριστείς την επιστροφή χρήστη που έχει ήδη συμφωνήσει (φόρτωσε το script αμέσως αν υπάρχει consent στο localStorage), να αποφύγεις hydration races και να μην κάνεις double-initialisation του tag. Το Consent Mode σου δίνει ένα tag, ένα μονοπάτι αρχικοποίησης και ένα απλό state flip.
Τι θεωρείται «αναγκαίο» cookie κάτω από το GDPR; Αναγκαία είναι αυτά που χρειάζεται το site για να λειτουργήσει — session tokens, CSRF tokens, load-balancer stickiness. Τα analytics, personalisation και advertising cookies δεν είναι αναγκαία, ό,τι κι αν λέει η πολιτική απορρήτου. Αν το site δουλεύει χωρίς αυτό, δεν είναι αναγκαίο.
Λειτουργεί αυτό αν προσθέσω αργότερα advertising ή remarketing tags;
Ναι. Προσθέτεις τα σχετικά σήματα (ad_storage, ad_user_data, ad_personalization) στην κλήση consent update και τα αντιστοιχείς στις κατηγορίες σου. Η προεπιλογή Consent Mode αρνείται ήδη και τα τέσσερα· θέλεις μόνο να προσθέσεις UI controls για τις νέες κατηγορίες και να ενημερώσεις ανάλογα την κλήση gtag('consent', 'update', ...).
