If you look after a website for yourself or a client, "how did the site do last week?" is a question that takes ten minutes in Google Analytics 4 and that nobody enjoys answering. A raw export of a GA4 report doesn't tell anyone what changed, and Looker Studio dashboards are great until you realise nobody opens them.
What most people actually want is one short email on Monday morning: the key numbers, whether they went up or down, and two sentences in plain English about what matters. Here is how to build exactly that in n8n with the built-in Google Analytics node, a Code node and Google Gemini's free tier.
What the email looks like
- A one-line headline ("Traffic up 12% with organic search leading the growth")
- A table: Users, New users, Sessions, Page views, Key events, this week vs last week, with green and red arrows
- Engagement rate
- 3 wins, up to 3 concerns, 3 next actions
- Top 8 pages and top 8 traffic sources
The important design rule: the numbers come from GA4 and a Code node, never from the AI. The model only writes the words around numbers it is given.
The architecture
Schedule Trigger (Monday 08:00)
-> Set: report settings (property ID, site name, recipient, brand colour)
-> Google Analytics: this week's daily metrics (days -7 to -1)
-> Google Analytics: last week's daily metrics (days -14 to -8)
-> Google Analytics: top pages (by page views, limit 10)
-> Google Analytics: top traffic sources (by sessions, limit 10)
-> Code: week-over-week totals and % changes
-> Basic LLM Chain + Gemini + Structured Output Parser
-> Code: build HTML email
-> Gmail: send
Step 1: Pull two comparable windows
The GA4 node (resource: report, GA4) takes a custom date range. Use expressions so it always covers the last full seven days and the seven before:
This week: start {{ $now.minus({ days: 7 }).toISODate() }} end {{ $now.minus({ days: 1 }).toISODate() }}
Last week: start {{ $now.minus({ days: 14 }).toISODate() }} end {{ $now.minus({ days: 8 }).toISODate() }}
Metrics: totalUsers, newUsers, sessions, screenPageViews, engagedSessions, keyEvents. Dimension: date. Turn on Return All.
Why daily rows instead of a single total? Two reasons. It makes the Code node simple and predictable (just sum), and if you later want a sparkline or a "best day" line, the data is already there.
One caveat: summing daily totalUsers slightly over-counts people who visited on several days. For a weekly trend email this is fine, since both weeks are measured the same way, but don't put that number next to the GA4 UI figure and expect them to match. If exact uniques matter, make a second call without the date dimension.
Top pages use dimension pagePath with metric screenPageViews sorted descending, limit 10. Top sources use sessionSourceMedium with sessions.
Step 2: Do the maths in code
const sum = (rows, k) => rows.reduce((t, r) => t + (Number(r[k]) || 0), 0);
const cur = $("Get this week's traffic").all().map(i => i.json);
const prev = $("Get last week's traffic").all().map(i => i.json);
const metrics = ['totalUsers', 'newUsers', 'sessions', 'screenPageViews', 'keyEvents'];
const labels = { totalUsers: 'Users', newUsers: 'New users', sessions: 'Sessions',
screenPageViews: 'Page views', keyEvents: 'Key events' };
const kpis = metrics.map(m => {
const c = sum(cur, m), p = sum(prev, m);
return { metric: labels[m], current: Math.round(c), previous: Math.round(p),
change_pct: p ? Math.round(1000 * (c - p) / p) / 10 : null };
});
const engaged = sum(cur, 'engagedSessions'), sess = sum(cur, 'sessions');
return [{ json: {
kpis,
engagement_rate_pct: sess ? Math.round(1000 * engaged / sess) / 10 : null,
top_pages: $('Get top pages').all().map(i => i.json).slice(0, 8)
.map(r => ({ page: r.pagePath, views: Number(r.screenPageViews) })),
top_sources: $('Get top traffic sources').all().map(i => i.json).slice(0, 8)
.map(r => ({ source: r.sessionSourceMedium, sessions: Number(r.sessions) })),
} }];
Note change_pct: null when last week was zero. Dividing by zero gives Infinity, and you do not want an email that says key events went up by Infinity%.
GA4 returns metric values as strings, hence the Number() everywhere.
Step 3: Let the AI write, not calculate
The prompt hands Gemini the computed JSON and is explicit about what it is not allowed to do:
You are a web analytics consultant writing a weekly note for a busy small-business owner.
Use ONLY the numbers below; do not invent causes you cannot see in the data.
Mention percentages from the data.
Site: {{ site_name }}
KPIs (this week vs last week): {{ JSON.stringify($json.kpis) }}
Engagement rate this week: {{ $json.engagement_rate_pct }}%
Top pages: {{ JSON.stringify($json.top_pages) }}
Top sources: {{ JSON.stringify($json.top_sources) }}
Return a headline under 12 words, 3 wins, up to 3 concerns and 3 specific
next actions (each under 20 words).
"Do not invent causes" is the line that matters most. Without it, LLMs love to explain a 4% dip with "seasonal factors" or "a recent algorithm update" that they cannot possibly know about.
A Structured Output Parser keeps the response predictable:
{
"headline": "Traffic up 12% with organic search leading the growth",
"wins": ["Organic sessions +18% week over week"],
"concerns": ["Engagement rate fell 3 points"],
"actions": ["Refresh the top landing page's call to action"]
}
Step 4: Build an email people will read
A second Code node renders HTML with inline styles (email clients ignore most <style> blocks). Deltas get coloured arrows:
const delta = v => v === null ? '–'
: `<span style="color:${v >= 0 ? '#1a7f37' : '#cf222e'}">${v >= 0 ? '▲' : '▼'} ${Math.abs(v)}%</span>`;
Escape anything that came from GA4 or the AI (&, <, >) before putting it into HTML. Page paths with query strings will otherwise break the layout sooner or later.
Then a Gmail node sends it with the HTML as the message body.
Setup
- In Google Cloud, enable the Google Analytics Data API for the project behind your OAuth client.
- In n8n, add a Google Analytics OAuth2 credential and pick it on all four GA4 nodes.
- Add a free Google Gemini (PaLM) API credential (key from aistudio.google.com) and a Gmail OAuth2 credential.
- Fill in the Set node: GA4 property ID (GA4 Admin > Property details, digits only), site name, recipient.
- Set the workflow timezone in Workflow settings, so "Monday 8am" and
$nowmean your Monday. - Run once manually, check the email, then activate.
Gotchas
-
Property ID, not Measurement ID. The
G-XXXXXXXID is for the tracking tag. The API wants the numeric property ID. - Key events need to be configured. If you never marked any event as a key event in GA4, that row will be zero every week. Swap it for a specific event name if that suits you better.
-
Today is incomplete. End the window yesterday (
minus({ days: 1 })), otherwise Monday's email compares six and a bit days with seven. - GA4 data is not instant. Some metrics settle over 24 to 48 hours. If your numbers are jumpy, shift both windows back a day.
- Model choice is flexible. Any n8n chat model node can replace Gemini; the prompt and parser stay the same.
Scaling to clients
The free approach above is one property and one recipient. If you are a freelancer or agency, the obvious next step is a loop: one Google Sheet row per client (property ID, recipient, brand colour, alert threshold), one branded report each, with a "draft" mode so you can check before it goes out. I built that as a paid Pro template: every client from one sheet, send or Gmail-draft per client, big-change flags the AI is asked to address, a Telegram alert to you when a client has a big drop, a report log tab and an error branch. It's $29 on Payhip: Weekly GA4 client reports Pro. Other templates are on the store and my n8n creator page.
If you build your own version, I'd love to hear which metrics your clients actually care about.
The free workflow JSON for this and 24 other n8n templates is on GitHub: n8n-ai-workflow-templates.
Top comments (0)