# SEO Link Previews for Flutter Web with Netlify Edge Functions Flutter web apps are Single Page Applications (SPAs). When a bot — Facebook, WhatsApp, Twitter — fetches a shared link, it receives the bare `index.html` shell with no article-specific content. Link previews show the generic app title and description instead of the article. This documents how we solved that using Netlify Edge Functions, and how we debugged them on the free tier where logs are not available in the dashboard. --- ## The Problem A shared article link like `https://news-byte.com/article/30629` loads a Flutter app that renders content client-side via JavaScript. Bots don't execute JavaScript. They see: ```html <title>News Byte</title> <meta property="og:title" content="News Byte"> <meta property="og:description" content="Latest news, curated for you."> ``` Instead of the actual article title and summary. --- ## The Solution: Edge Functions Netlify Edge Functions run on Deno at the CDN edge before the static file is served. **What is Deno?** Deno is a JavaScript/TypeScript runtime built by the creator of Node.js as a modern replacement for it. Key differences from Node: it runs TypeScript natively without a build step, has no `node_modules` — dependencies are imported by URL, and has a security-first model where scripts have no file/network access unless explicitly granted. Netlify chose Deno for edge functions because it starts up faster than Node (important at the CDN edge where cold starts are measured in milliseconds) and supports TypeScript out of the box. We intercept requests to `/article/:id`, detect if the requester is a bot, and if so return a server-rendered HTML page with the correct Open Graph and Twitter Card meta tags. Non-bot requests (real browsers) pass through untouched to the Flutter app. ### File: `netlify/edge-functions/article-meta.ts` ```typescript const WEB_BASE_URL = Deno.env.get('WEB_BASE_URL') ?? 'https://news-byte.com'; const API_BASE_URL = Deno.env.get('API_BASE_URL') ?? 'https://api.news-byte.com'; const BOT_AGENTS = /facebookexternalhit|twitterbot|whatsapp|slackbot|telegrambot|linkedinbot|googlebot|bingbot|applebot|iframely|embedly/i; export default async (request: Request) => { const agentCategory = request.headers.get('netlify-agent-category') ?? ''; const ua = request.headers.get('user-agent') ?? ''; const isBot = agentCategory.startsWith('page-preview') || agentCategory.startsWith('crawler') || BOT_AGENTS.test(ua); if (!isBot) return; const url = new URL(request.url); const articleId = url.pathname.split('/').pop(); const api = await fetch(`${API_BASE_URL}/v2/news/${articleId}`); if (!api.ok) return; const article = await api.json(); const articleUrl = `${WEB_BASE_URL}/article/${articleId}`; const image = `${WEB_BASE_URL}/icons/Icon-512.png`; const description = ((article.summary ?? '') as string).slice(0, 160); const title = (article.title ?? 'News Byte') as string; const html = `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>${title} | News Byte</title> <meta name="description" content="${description}"> <link rel="canonical" href="${articleUrl}"> <meta property="og:type" content="article"> <meta property="og:site_name" content="News Byte"> <meta property="og:title" content="${title}"> <meta property="og:description" content="${description}"> <meta property="og:url" content="${articleUrl}"> <meta property="og:image" content="${image}"> <meta property="og:image:width" content="512"> <meta property="og:image:height" content="512"> <meta name="twitter:card" content="summary_large_image"> <meta name="twitter:title" content="${title}"> <meta name="twitter:description" content="${description}"> <meta name="twitter:image" content="${image}"> </head> <body> <a href="${articleUrl}">${title}</a> </body> </html>`; return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' }, }); }; export const config = { path: '/article/:id' }; ``` ### Two ways bots are detected Netlify itself injects a `netlify-agent-category` header on edge requests. Bots get values like `page-preview; general` or `crawler; ...`. This is the primary signal. The User-Agent regex is a fallback for any bot that slips through without the Netlify header. > **Important:** The `netlify-agent-category` value is `"page-preview; general"` — not just `"page-preview"`. Use `startsWith('page-preview')`, not strict equality (`===`), or the check will always miss real bot traffic. --- ## Testing Locally With `netlify dev` running, the edge function is active at `http://localhost:8888`. Test it by spoofing the bot header: ```bash curl -H "netlify-agent-category: page-preview" \ http://localhost:8888/article/30629 ``` You should receive a full HTML page with article meta tags. A normal browser request to the same URL loads the Flutter app as usual. --- ## Debugging on Netlify Free Tier Netlify's free tier does not expose function or edge function logs in the dashboard. This makes it hard to verify behaviour in production — especially when bots like Facebook or WhatsApp fetch links from their own servers in a way you can't easily replicate locally. ### The approach: a local log receiver exposed via ngrok The idea is simple: 1. The edge function POSTs request details (headers, URL, agent category) to a URL you control. 2. That URL is your local machine, exposed publicly via ngrok. 3. You see the logs in your terminal and in a local file. ### Step 1: Create a log server Save this as `log-server.js` at the project root. It has no dependencies — just Node.js built-ins. ```javascript const http = require('http'); const fs = require('fs'); const path = require('path'); const PORT = process.env.PORT || 4000; const LOG_FILE = path.join(__dirname, 'edge-logs.ndjson'); const server = http.createServer((req, res) => { if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return; } let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', () => { try { const data = JSON.parse(body); const entry = JSON.stringify({ ts: new Date().toISOString(), ...data }); fs.appendFileSync(LOG_FILE, entry + '\n'); console.log('\n--- request ---'); console.log(JSON.stringify(data, null, 2)); } catch (e) { console.error('Bad payload:', e.message); } res.writeHead(200); res.end(); }); }); server.listen(PORT, () => { console.log(`Listening on http://localhost:${PORT}`); console.log(`Logs saved to ${LOG_FILE}`); }); ``` ### Step 2: Add logging to the edge function (temporarily) ```typescript const LOG_URL = Deno.env.get('LOG_URL') ?? 'https://your-ngrok-url.ngrok-free.app'; // Inside the handler, before the isBot check: const logData = { url: request.url, agentCategory, ua, isBot, headers: Object.fromEntries(request.headers), }; console.log('[article-meta]', JSON.stringify(logData)); if (LOG_URL) { fetch(LOG_URL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(logData), }).catch(() => {}); // fire and forget — don't block the response } ``` ### Step 3: Expose your machine via ngrok ```bash # Terminal 1 — start the log server node log-server.js # Terminal 2 — expose it ngrok http 4000 ``` ngrok prints a public URL like `https://8bdb-41-90-209-125.ngrok-free.app`. Paste that as the `LOG_URL` value in the edge function (or set it as a Netlify environment variable). ### Step 4: Deploy and test Push the branch to Netlify, then share an article link on WhatsApp or paste it into [opengraph.xyz](https://www.opengraph.xyz). Requests from those bots will tunnel through ngrok to your local server. --- ## What We Learned from the Logs Once real bot traffic came in, the logs revealed several things: **`netlify-agent-category` is always `"page-preview; general"`**, not `"page-preview"`. Strict equality would always fail. The fix is `startsWith('page-preview')`. **Facebook sends 4–5 parallel requests simultaneously** from different IPs and regions (US East, US West, Sweden). Their crawler is globally distributed and hits the same URL from multiple nodes at once. **Facebook's first requests include a `Range` header** (`bytes=0-524287`). It attempts a partial content fetch before falling back to a full GET. The edge function handles this fine since it returns the full HTML regardless. **Regular browser requests also pass through the edge function**, with `netlify-agent-category: browser` and `isBot: false`. They return immediately without hitting the API. No performance impact. --- ## Cleanup Once debugging is done, remove the `LOG_URL` constant and the logging block from the edge function. The `startsWith` fix stays. Add `edge-logs.ndjson` to `.gitignore` so the log file doesn't get committed. ## References https://docs.netlify.com/build/edge-functions/get-started/
← Back to blog