<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Ilytics Blog]]></title><description><![CDATA[Notes from building Ilytics, a privacy-first web analytics tool: Next.js SEO fixes, GDPR and ePrivacy news, and real bugs explained with code.]]></description><link>https://ilytics.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ade7bf7b9b5c8ab7b230547/98066f6b-d764-4fdd-82e8-23cd30878fae.png</url><title>Ilytics Blog</title><link>https://ilytics.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 11:34:23 GMT</lastBuildDate><atom:link href="https://ilytics.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Next.js App Router SEO checklist: 9 things I fixed on my own site]]></title><description><![CDATA[A Next.js 15 App Router SEO checklist from a real audit: Suspense bailouts, canonicals, metadataBase, sitemaps and JSON-LD, each with a quick check.]]></description><link>https://ilytics.hashnode.dev/next-js-app-router-seo-checklist-9-things-i-fixed-on-my-own-site</link><guid isPermaLink="true">https://ilytics.hashnode.dev/next-js-app-router-seo-checklist-9-things-i-fixed-on-my-own-site</guid><category><![CDATA[Next.js]]></category><category><![CDATA[SEO]]></category><category><![CDATA[webdev]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[Arsany]]></dc:creator><pubDate>Wed, 04 Nov 2026 13:31:05 GMT</pubDate><content:encoded><![CDATA[<p>Last week I audited the marketing site for Ilytics, the analytics tool I've been building, after Search Console showed almost nothing indexed. The homepage that Googlebot received had one word on it. Fixing that led to a full pass over the site's SEO.</p>
<p>This is the checklist I ended up with. Every item is something I actually found and fixed on a Next.js 15 App Router site. Each one has a way to check your own site in under a minute.</p>
<h2>1. See your page the way a crawler does</h2>
<p>Before changing anything, look at the HTML your server sends, not what your browser renders after JavaScript runs.</p>
<pre><code class="language-bash">curl -s -A "Googlebot" https://yoursite.com/ | sed 's/&lt;[^&gt;]*&gt;/ /g' | wc -w
</code></pre>
<p>This counts the words in the raw HTML. My homepage returned 1. After the fixes below it returned 375. If your number is tiny compared with what you see on screen, something is client-rendering your content.</p>
<h2>2. <code>useSearchParams</code> can client-render your whole page</h2>
<p>This was the big one. I had a pageview tracker that called <code>useSearchParams()</code>, wrapped in a <code>&lt;Suspense&gt;</code> that also wrapped <code>{children}</code> in the root layout.</p>
<p>The Next.js docs say that on a statically rendered route, <code>useSearchParams</code> makes the client component tree up to the closest Suspense boundary render on the client. My closest boundary contained the entire site.</p>
<p>The fix is to give the hook its own tiny boundary:</p>
<pre><code class="language-tsx">// app/providers.tsx
export function Providers({ children }: { children: React.ReactNode }) {
  return (
    &lt;PostHogProvider client={posthog}&gt;
      {children}
      &lt;Suspense fallback={null}&gt;
        &lt;PageviewTracker /&gt;
      &lt;/Suspense&gt;
    &lt;/PostHogProvider&gt;
  );
}
</code></pre>
<p>Now only the tracker waits for the browser. Everything else renders on the server.</p>
<h2>3. Set <code>metadataBase</code></h2>
<p>Without it, relative Open Graph image URLs and canonicals can't be turned into absolute URLs. Next.js warns about this in the terminal, and the warning is easy to miss.</p>
<pre><code class="language-ts">// app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL("https://yoursite.com"),
  title: { default: "Your product: what it does", template: "%s | Your product" },
};
</code></pre>
<h2>4. One canonical per page</h2>
<p>Each route should declare its own canonical, or Google picks one for you:</p>
<pre><code class="language-ts">export const metadata: Metadata = {
  alternates: { canonical: "/pricing" },
};
</code></pre>
<p>For dynamic routes, return it from <code>generateMetadata</code> using the slug.</p>
<h2>5. Don't repeat the brand twice in titles</h2>
<p>With a <code>title.template</code> of <code>"%s | Brand"</code>, a page whose own title is <code>"Privacy | Brand"</code> ends up as <code>"Privacy | Brand | Brand"</code>. I had this on two legal pages. Give pages only the part before the separator.</p>
<h2>6. Pages with <code>"use client"</code> can't export metadata</h2>
<p>My pricing and FAQ pages were client components, so their <code>export const metadata</code> did nothing. They fell back to the homepage title.</p>
<p>The fix is a small <code>layout.tsx</code> next to the page that exports the metadata. The page itself stays a client component.</p>
<pre><code class="language-ts">// app/(marketing)/pricing/layout.tsx
export const metadata: Metadata = {
  title: "Pricing",
  description: "One plan, 14-day free trial.",
  alternates: { canonical: "/pricing" },
};
export default function Layout({ children }: { children: React.ReactNode }) {
  return children;
}
</code></pre>
<h2>7. Generate the sitemap from your real routes</h2>
<p>A hand-written sitemap goes stale. <code>app/sitemap.ts</code> can build it from the same data your pages use:</p>
<pre><code class="language-ts">import type { MetadataRoute } from "next";
import { DOCS } from "@/lib/docs";

export default function sitemap(): MetadataRoute.Sitemap {
  const base = "https://yoursite.com";
  const pages = ["", "/pricing", "/faq", "/docs"].map((p) =&gt; ({ url: base + p, lastModified: new Date() }));
  const docs = DOCS.map((d) =&gt; ({ url: `${base}/docs/${d.slug}`, lastModified: new Date() }));
  return [...pages, ...docs];
}
</code></pre>
<p>Mine went from 4 URLs to 15 once the docs pages were included.</p>
<h2>8. Only put true things in structured data</h2>
<p>My JSON-LD had an <code>aggregateRating</code> left over from a template, for reviews that didn't exist. Google's structured data guidelines say markup has to reflect content that's actually on the page, and made-up ratings can get the page a manual action.</p>
<p>I removed it and kept a simple <code>@graph</code> with <code>Organization</code>, <code>WebSite</code> and <code>SoftwareApplication</code>. I also deleted some leftover geo meta tags for a city I've never been to.</p>
<h2>9. Tell search engines when things change</h2>
<p>After deploying, I submitted the sitemap in Search Console and pinged IndexNow (used by Bing and Yandex). IndexNow needs a key file in <code>public/</code>, then one request with your changed URLs. Search Console shows changes over days, not minutes, so check back after a week.</p>
<h2>The order I'd do it in</h2>
<ol>
<li>Run the curl word count.</li>
<li>Fix any Suspense or client-rendering bailout.</li>
<li>Add metadata, canonicals and titles.</li>
<li>Add the sitemap and clean up structured data.</li>
<li>Resubmit to Search Console and IndexNow.</li>
</ol>
<p>The first two steps matter more than everything else combined. If the HTML is empty, nothing else on this list gets read.</p>
<p>Sources: <a href="https://nextjs.org/docs/app/api-reference/functions/use-search-params">Next.js useSearchParams docs</a>, <a href="https://nextjs.org/docs/app/api-reference/functions/generate-metadata">Next.js metadata docs</a>, <a href="https://developers.google.com/search/docs/appearance/structured-data/sd-policies">Google structured data guidelines</a>, <a href="https://www.indexnow.org/">IndexNow</a>.</p>
<p>Arsany</p>
]]></content:encoded></item><item><title><![CDATA[Google saw 1 word on my Next.js homepage. A Suspense boundary was hiding everything else.]]></title><description><![CDATA[A Suspense boundary around useSearchParams made every Next.js page send empty HTML to crawlers. How I found it, fixed it, and how to check yours.]]></description><link>https://ilytics.hashnode.dev/google-saw-1-word-on-my-next-js-homepage-a-suspense-boundary-was-hiding-everything-else</link><guid isPermaLink="true">https://ilytics.hashnode.dev/google-saw-1-word-on-my-next-js-homepage-a-suspense-boundary-was-hiding-everything-else</guid><category><![CDATA[Next.js]]></category><category><![CDATA[SEO]]></category><category><![CDATA[webdev]]></category><category><![CDATA[React]]></category><dc:creator><![CDATA[Arsany]]></dc:creator><pubDate>Wed, 04 Nov 2026 13:28:00 GMT</pubDate><content:encoded><![CDATA[<p>Last night I ran an SEO audit on Ilytics, the analytics tool I've been building, and it came back with 0 words on every page.</p>
<p>I assumed the audit tool was broken. The site looked fine in my browser. So I fetched the homepage the way a crawler does, with curl and a Googlebot user agent, and counted the words in the HTML. There was exactly one: "Ilytics", from the page title. No headings, no text, no links to my other pages.</p>
<p>The fix ended up being about twenty lines. It's an easy mistake to make in the App Router, so I'm writing it up.</p>
<h2>The provider that wrapped everything</h2>
<p>My root layout wraps the whole app in an analytics provider, which is how most PostHog setups for Next.js look:</p>
<pre><code class="language-tsx">// app/layout.tsx
&lt;PostHogProvider&gt;
  {children}
&lt;/PostHogProvider&gt;
</code></pre>
<p>Inside, the provider reads the URL so it can send a pageview whenever the path or query string changes. Because <code>useSearchParams()</code> needs a Suspense boundary, I had wrapped it in one:</p>
<pre><code class="language-tsx">"use client";

function PostHogInner({ children }) {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  // ...init, identify, pageview tracking...
  return &lt;PHProvider client={posthog}&gt;{children}&lt;/PHProvider&gt;;
}

export function PostHogProvider({ children }) {
  return (
    &lt;Suspense fallback={null}&gt;
      &lt;PostHogInner&gt;{children}&lt;/PostHogInner&gt;
    &lt;/Suspense&gt;
  );
}
</code></pre>
<h2>Why that empties the page</h2>
<p>When a statically rendered page calls <code>useSearchParams()</code>, Next.js can't know the query string at build time. So everything up to the nearest Suspense boundary gets rendered in the browser instead of on the server, and the server HTML contains the boundary's fallback.</p>
<p>My boundary wrapped <code>{children}</code>, which in a root layout is the entire site. The fallback was <code>null</code>. So every page was pre-rendered as nothing, and JavaScript filled it in after load.</p>
<p>Visitors never noticed because it happens in a fraction of a second. Crawlers that read the server HTML got an empty page.</p>
<h2>The fix is to shrink the boundary</h2>
<p>Only the part that reads the query string needs to wait for the browser. I moved pageview tracking into its own component, gave it its own Suspense boundary, and rendered <code>children</code> outside of it:</p>
<pre><code class="language-tsx">"use client";

function PageviewTracker() {
  const pathname = usePathname();
  const searchParams = useSearchParams();

  useEffect(() =&gt; {
    ensurePostHog();
    posthog.capture("$pageview", { $current_url: window.location.href });
  }, [pathname, searchParams]);

  return null;
}

export function PostHogProvider({ children }) {
  // session and identify logic stays here, without useSearchParams
  return (
    &lt;PHProvider client={posthog}&gt;
      {children}
      &lt;Suspense fallback={null}&gt;
        &lt;PageviewTracker /&gt;
      &lt;/Suspense&gt;
    &lt;/PHProvider&gt;
  );
}
</code></pre>
<p>The boundary now wraps a component that renders <code>null</code> anyway, so nothing is lost, and the actual pages render on the server again.</p>
<p>One thing broke along the way. React runs a child component's effects before its parent's, so the tracker started calling <code>capture()</code> before the parent had called <code>posthog.init()</code>. I moved the init into a small <code>ensurePostHog()</code> function that both components call, with a flag so it only runs once.</p>
<h2>Before and after</h2>
<p>I fetched the same pages as Googlebot before and after deploying. Visible words in the HTML:</p>
<ul>
<li>Home: 1 before, 375 after</li>
<li>Pricing: 0 before, 211 after</li>
<li>FAQ: 0 before, 159 after</li>
<li>Docs: 0 before, 138 after</li>
</ul>
<p>Each page also went from zero to one H1, and from no internal links in the HTML to between 6 and 15.</p>
<h2>How to check your own site</h2>
<p>Run this against any page and compare the number with what you see in the browser:</p>
<pre><code class="language-bash">curl -s -A "Googlebot" https://your-site.com/ \
  | sed -E 's/&lt;script[^&gt;]*&gt;.*&lt;\/script&gt;//g; s/&lt;[^&gt;]+&gt;/ /g' \
  | wc -w
</code></pre>
<p>If it's close to zero, look for <code>useSearchParams()</code> or other client-only hooks inside a Suspense boundary that sits high up in your layout.</p>
<p>Google does render JavaScript eventually, but it's slower and less reliable than reading HTML, and many other crawlers don't run JavaScript at all. I'd rather not depend on it.</p>
<h2>What I'd check next time</h2>
<p>If you add any tracking or auth provider to a Next.js layout, check where its Suspense boundary sits before you ship. The Next.js docs on <a href="https://nextjs.org/docs/app/api-reference/functions/use-search-params"><code>useSearchParams</code></a> explain the client-side rendering behavior in more detail.</p>
<p>It's a little embarrassing that the thing hiding my analytics product from search was an analytics provider. At least it made for a good debugging session.</p>
<p>Arsany</p>
]]></content:encoded></item><item><title><![CDATA[The EU Just Moved to Make Website Analytics Consent-Free. Here's the Catch.]]></title><description><![CDATA[The EU Council backed consent-free, anonymous first-party analytics. What it covers, what still needs a banner, and why nothing changes yet.]]></description><link>https://ilytics.hashnode.dev/the-eu-just-moved-to-make-website-analytics-consent-free-here-s-the-catch</link><guid isPermaLink="true">https://ilytics.hashnode.dev/the-eu-just-moved-to-make-website-analytics-consent-free-here-s-the-catch</guid><category><![CDATA[privacy]]></category><category><![CDATA[#gdpr]]></category><category><![CDATA[analytics]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Arsany]]></dc:creator><pubDate>Wed, 04 Nov 2026 13:25:21 GMT</pubDate><content:encoded><![CDATA[<p>On October 21, 2026, EU ambassadors agreed the Council's position on the Digital Omnibus, a package that amends the GDPR and the ePrivacy rules. Buried in its 179 pages is a line every website owner should read: anonymous, first-party audience measurement could run without asking for consent.</p>
<p>In other words, simple analytics may no longer need a cookie banner. But there is a catch, and a long timeline.</p>
<h2>What the Council agreed</h2>
<p>The Council kept cookie rules where they are today, in the ePrivacy Directive. Consent stays the general rule. What changes is the list of exceptions: device access without consent would be allowed only when it is strictly necessary for one of six purposes.</p>
<p>One of those purposes is anonymous, aggregated audience measurement of your own service. It can be run by you or by a provider on your behalf, as long as the data is:</p>
<ul>
<li>anonymous and aggregated</li>
<li>used only to measure your own service</li>
<li>not shared with third parties</li>
<li>not combined with third-party data</li>
<li>not reused for any other purpose</li>
</ul>
<p>The mandate also requires a one-click way to refuse cookies, and bars sites from asking again for at least four months after a refusal. The Commission had proposed six.</p>
<h2>What still needs consent</h2>
<p>The exemption is narrow on purpose. Consent is still required for:</p>
<ul>
<li>cross-site tracking</li>
<li>advertising profiles</li>
<li>analytics that merges your stats with outside data</li>
<li>anything that is not anonymous and aggregated</li>
</ul>
<p>So the cookie banner is not dead. It is simply no longer meant for basic "how many people visited" analytics.</p>
<h2>When it would actually apply</h2>
<p>Nothing changes today. Here is the path ahead:</p>
<table>
<thead>
<tr>
<th>Step</th>
<th>Status</th>
</tr>
</thead>
<tbody><tr>
<td>Council mandate agreed by Coreper</td>
<td>Done, Oct 21, 2026</td>
</tr>
<tr>
<td>European Parliament adopts its position</td>
<td>Not yet, as of Oct 24, 2026</td>
</tr>
<tr>
<td>Council and Parliament negotiate the final text</td>
<td>Starts after Parliament's position</td>
</tr>
<tr>
<td>Member states transpose the cookie changes</td>
<td>24 months after adoption</td>
</tr>
</tbody></table>
<p>The EU wants its simplification packages done by the end of 2027, so legal analysts expect the cookie changes to take effect around 2028 at the earliest. Until then, current GDPR and ePrivacy rules apply in full.</p>
<h2>Why it matters anyway</h2>
<p>Even before it becomes law, the direction is clear. Europe is drawing a line between analytics that measures your own site and analytics that tracks people. The first is being made easier. The second keeps its friction.</p>
<p>For developers and founders, that means the less your analytics collects, the less it will cost you, both in legal work and in the visitors you lose to consent pop-ups.</p>
<p>I'm building Ilytics, a small analytics tool, around the same idea, so I'll be watching how Parliament's position turns out.</p>
<p><em>This article summarizes public reporting on a draft legislative position. It is not legal advice.</em></p>
<h2>Sources</h2>
<ul>
<li><a href="https://news.europawire.eu/council-of-the-eu-backs-digital-omnibus-changes-to-gdpr-cookie-consent-and-cyber-incident-reporting/eu-press-release/2026/10/22/12/17/03/183831/">Council of the EU backs Digital Omnibus changes (EU press release via EuropaWire)</a></li>
<li><a href="https://ppc.land/eu-council-agrees-4-month-cookie-re-ask-wait-for-digital-omnibus-talks/">EU Council agrees 4-month cookie re-ask wait (PPC Land)</a></li>
<li><a href="https://legal247.no/en/insights/digital-omnibus-gdpr/">Digital Omnibus and the GDPR: the Council mandate (Legal247)</a></li>
</ul>
]]></content:encoded></item></channel></rss>