<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mitrasish</title>
    <description>The latest articles on DEV Community by Mitrasish (@mitrasish).</description>
    <link>https://dev.to/mitrasish</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4016313%2Fa9f7833d-de18-430c-85ad-82080d632c57.jpeg</url>
      <title>DEV Community: Mitrasish</title>
      <link>https://dev.to/mitrasish</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mitrasish"/>
    <language>en</language>
    <item>
      <title>Astro vs Next.js SEO: Core Web Vitals and the Catch</title>
      <dc:creator>Mitrasish</dc:creator>
      <pubDate>Thu, 09 Jul 2026 20:30:00 +0000</pubDate>
      <link>https://dev.to/mitrasish/astro-vs-nextjs-seo-core-web-vitals-and-the-catch-20hg</link>
      <guid>https://dev.to/mitrasish/astro-vs-nextjs-seo-core-web-vitals-and-the-catch-20hg</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.trylyra.ai/blog/astro-vs-nextjs-seo/" rel="noopener noreferrer"&gt;the Lyra blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Astro vs Next.js SEO comes down to one variable neither framework can dodge: how much JavaScript reaches the browser before your Core Web Vitals scores lock in. Get that variable right and canonicals, sitemaps, and structured data are a solved problem in both frameworks. Get it wrong and no amount of metadata tuning saves a slow page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Astro vs Next.js SEO: the short answer
&lt;/h2&gt;

&lt;p&gt;Astro wins Core Web Vitals by default for a content-only blog: its islands architecture ships no JavaScript unless a component asks for it, so Largest Contentful Paint (LCP) and Interaction to Next Paint (INP) start clean without any tuning. Next.js's App Router closes most of that gap with Server Components and streaming, and pulls ahead the moment the site needs auth, a dashboard, or per-user content sitting next to the blog.&lt;/p&gt;

&lt;p&gt;Neither answer is universal, and that is the actual decision repo-based blog owners face. A marketing site with a blog bolted on has different constraints than a SaaS product with a blog living inside the same app as the dashboard. The rest of this post works through why JavaScript weight drives the scores, where each framework earns its keep, and how to keep the SEO fundamentals airtight regardless of which one you pick.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why JavaScript weight moves Core Web Vitals in the first place
&lt;/h2&gt;

&lt;p&gt;Every kilobyte of JavaScript the browser downloads, parses, and executes competes with the work of painting your content and responding to a click. A framework's default JavaScript footprint is not a cosmetic detail; it is the single biggest lever most sites have over their Core Web Vitals scores, ahead of image optimization, font loading, or CDN choice.&lt;/p&gt;

&lt;p&gt;That lever matters because most sites are still failing the test. The &lt;a href="https://almanac.httparchive.org/en/2025/performance" rel="noopener noreferrer"&gt;2025 Web Almanac&lt;/a&gt; measured that only 48% of websites achieve "good" Core Web Vitals on mobile and 56% on desktop, against the standard thresholds of LCP at or under 2.5 seconds, INP at or under 200 milliseconds, and Cumulative Layout Shift (CLS) at or under 0.1. Just over half the web clears the bar on desktop; fewer than half do on mobile, where JavaScript's cost in parse and execution time is highest.&lt;/p&gt;

&lt;h3&gt;
  
  
  LCP, CLS, and INP: what each one actually measures
&lt;/h3&gt;

&lt;p&gt;LCP measures how long it takes the largest visible element, usually a hero image or the main heading, to render. CLS measures how much visible content shifts unexpectedly after it first appears, the classic case being an ad or image that loads late and pushes the text you were reading down the page. INP measures how quickly the page responds to a user's interaction, replacing the older First Input Delay metric with a measure that covers the full interaction, not just the first one.&lt;/p&gt;

&lt;p&gt;All three are sensitive to JavaScript in different ways. A large JS bundle delays LCP because the main thread is busy parsing and executing instead of painting. It hurts CLS when late-loading scripts inject content into a page that already rendered without reserving space for it. It hurts INP directly: a busy main thread cannot respond to a click until it finishes whatever JavaScript task it is already running.&lt;/p&gt;

&lt;h3&gt;
  
  
  Astro's zero-JS-by-default model (islands architecture)
&lt;/h3&gt;

&lt;p&gt;Astro's core mechanism is the islands architecture: &lt;a href="https://docs.astro.build/en/concepts/islands/" rel="noopener noreferrer"&gt;every UI component renders to static HTML and CSS by default&lt;/a&gt;, with all client-side JavaScript stripped out automatically. Interactivity is opt-in per component through a &lt;code&gt;client:*&lt;/code&gt; directive, so a comment widget or a search box ships its own JavaScript while the surrounding article, headings, and images ship none.&lt;/p&gt;

&lt;p&gt;Astro's own documentation is direct about the reasoning: "This avoids the monolithic JavaScript payloads that slow down the responsiveness of many other, modern JavaScript web frameworks." For a blog post with no interactive elements at all, that means the page can ship effectively zero JavaScript and still render the full page, images included, which is why Astro's default Core Web Vitals scores for content sites are hard to beat without deliberate effort in another framework.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where Next.js's JS weight comes from, and how streaming/RSC cuts it
&lt;/h3&gt;

&lt;p&gt;The reputation Next.js has for heavier pages comes from its Pages Router era and from React apps in general, where every component, including static ones, shipped as client-side JavaScript to be hydrated in the browser. The App Router changes the default: &lt;a href="https://nextjs.org/docs/app/getting-started/server-and-client-components" rel="noopener noreferrer"&gt;pages are Server Components unless marked otherwise&lt;/a&gt;, and Server Components render on the server with their code and dependencies never sent to the client at all. A blog post with no interactive widgets can be built entirely of Server Components and ship no more JavaScript than an equivalent Astro page.&lt;/p&gt;

&lt;p&gt;Streaming is the other half of the story. The App Router streams HTML in chunks aligned to &lt;code&gt;&amp;lt;Suspense&amp;gt;&lt;/code&gt; boundaries instead of waiting for every data fetch to resolve before sending anything. Next.js's own guidance is blunt about the alternative: "Without streaming, the server waits for all data before sending any HTML, so TTFB equals the slowest query. With streaming, the server sends the static shell as soon as it's ready." That static shell, your layout, navigation, and fallback content, paints immediately, and Next.js's own advice is to keep the LCP element (a hero image or the main heading) outside any Suspense boundary so it renders in that shell rather than waiting on a slow fetch.&lt;/p&gt;

&lt;p&gt;Streaming also improves INP through selective hydration: each Suspense boundary hydrates independently, so React can prioritize hydrating whatever section the user is actually touching instead of blocking on the whole page in one pass. None of this is theoretical. &lt;a href="https://frigade.com/blog/bundle-size-reduction-with-rsc-and-frigade" rel="noopener noreferrer"&gt;Frigade's own case study&lt;/a&gt; moved a client-rendered Pages Router site to Server Components on the App Router. Bundle size dropped 62%. Speed Index, measured with Pingdom Speedtest and PageSpeed Insights, came in 63% faster. That gap did not come from a framework limitation; it came from an architecture the App Router was built to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Astro's zero-JS approach wins
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Content-first blogs, docs, and marketing sites
&lt;/h3&gt;

&lt;p&gt;If the site is mostly text, images, and links, with a handful of interactive widgets at most, Astro's default gets you a fast, low-JavaScript page without having to think about it. This is Vercel's own framing too, and Vercel maintains Next.js, so it has no reason to undersell Astro: &lt;a href="https://vercel.com/i/astro-vs-next-js" rel="noopener noreferrer"&gt;Vercel's comparison&lt;/a&gt; recommends Astro for "purely static content sites," naming blogs, documentation portals, marketing sites, and portfolios specifically, and describes its strength as shipping "minimal client-side JavaScript" with "isolated interactivity" for the few components that need it, like a search bar or a signup form.&lt;/p&gt;

&lt;p&gt;That is the profile of most repo-based blogs: a founder or a small team publishing posts as Markdown files, with a comment widget or a newsletter signup at most for interactivity. For that shape of site, reaching for Astro is not a performance hack, it is matching the tool to the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Next.js's weight is worth it
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Auth, dashboards, and personalization at scale
&lt;/h3&gt;

&lt;p&gt;The moment the site is not just a blog, when the same repo also serves a logged-in dashboard, a checkout flow, or content that varies per user, Next.js's App Router earns its extra weight. &lt;a href="https://nextjs.org/docs/app/guides/server-actions" rel="noopener noreferrer"&gt;Server Actions&lt;/a&gt; let a form submission run a mutation on the server. The caching and revalidation system then returns updated data and re-rendered UI in a single round trip, commonly gated behind an &lt;code&gt;auth()&lt;/code&gt; check inside the action itself.&lt;/p&gt;

&lt;p&gt;Astro does not compete on this ground by design. &lt;a href="https://docs.astro.build/en/guides/authentication/" rel="noopener noreferrer"&gt;Astro has no official first-party authentication solution&lt;/a&gt;; auth runs through community integrations or third-party providers like Supabase, Firebase, or Better Auth, wired up through Astro's middleware and &lt;code&gt;context.locals&lt;/code&gt;. That works, but it is assembled, not built in. Vercel's own comparison puts it plainly: Next.js is "the better choice" for "SaaS dashboards, collaborative tools, marketplaces, and commerce apps where every route involves server-side logic and user-specific data," and frames it as "the safer starting point" for a site that might grow from static content into app-like interactivity, since that growth does not require a framework migration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Astro's server islands: personalization without going full-app
&lt;/h3&gt;

&lt;p&gt;Astro's answer to that gap is narrower but real. &lt;a href="https://docs.astro.build/en/guides/server-islands/" rel="noopener noreferrer"&gt;Server Islands&lt;/a&gt;, enabled with a &lt;code&gt;server:defer&lt;/code&gt; directive, let a mostly-static page render instantly with fallback content while one specific dynamic or personalized component, a logged-in user's avatar, say, fetches and streams in separately, without making the rest of the page dynamic.&lt;/p&gt;

&lt;p&gt;That closes a meaningful slice of the personalization gap without adopting Next.js's full application model: a content site can show "Welcome back, Alex" in the corner while the article itself stays static and fast. It does not give Astro Server Actions, a first-party auth story, or the caching and revalidation system Next.js builds mutations around. For a blog with one personalized widget, Server Islands are usually enough. For an app where most routes need session state, Next.js is still the more direct route.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build speed: does it actually matter for a blog-sized repo
&lt;/h2&gt;

&lt;p&gt;Build time comes up in every framework debate, and for a blog-sized repo, it should not decide anything. Both frameworks statically generate content at build time, Astro by default, Next.js through &lt;code&gt;generateStaticParams&lt;/code&gt; in the App Router, and a repo with a few hundred Markdown posts builds in well under a couple of minutes on either one. The variable that actually separates them is runtime JavaScript weight, not the minutes your CI spends building the site. If build time is the deciding factor in your framework choice, that is usually a sign the real question, how much JavaScript ships to readers, has not been asked yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping SEO airtight in either framework
&lt;/h2&gt;

&lt;p&gt;Whichever framework you pick, the technical SEO fundamentals do not change: a self-referencing canonical, a sitemap that reflects your actual URLs, and structured data that validates. Both frameworks handle all three; the mechanics just differ.&lt;/p&gt;

&lt;h3&gt;
  
  
  Metadata, canonicals, and sitemaps (generateMetadata vs @astrojs/sitemap)
&lt;/h3&gt;

&lt;p&gt;In the Next.js App Router, each page's metadata, including its canonical URL, comes from &lt;code&gt;generateMetadata&lt;/code&gt;, and &lt;code&gt;sitemap.ts&lt;/code&gt; generates the sitemap, with a &lt;a href="https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap" rel="noopener noreferrer"&gt;&lt;code&gt;generateSitemaps&lt;/code&gt; function&lt;/a&gt; available to split output once you approach Google's cap of 50,000 URLs per sitemap file. These are Route Handlers, so they are cached by default. If you want that correctness enforced automatically rather than eyeballed per PR, our &lt;a href="https://www.trylyra.ai/blog/github-actions-seo-checks/" rel="noopener noreferrer"&gt;GitHub Actions SEO checks&lt;/a&gt; post covers a CI job that parses the built HTML for a missing canonical or a mismatch before merge.&lt;/p&gt;

&lt;p&gt;Astro's equivalent is the &lt;a href="https://docs.astro.build/en/guides/integrations-guide/sitemap/" rel="noopener noreferrer"&gt;&lt;code&gt;@astrojs/sitemap&lt;/code&gt; integration&lt;/a&gt;, which generates &lt;code&gt;sitemap-index.xml&lt;/code&gt; and &lt;code&gt;sitemap-0.xml&lt;/code&gt; at build time once you set the &lt;code&gt;site&lt;/code&gt; option in &lt;code&gt;astro.config&lt;/code&gt;. The one gap worth knowing up front: it cannot generate sitemap entries for dynamic routes when the site runs in SSR mode, which matters if you lean on Server Islands for personalized routes alongside a mostly-static blog.&lt;/p&gt;

&lt;h3&gt;
  
  
  Structured data and robots files
&lt;/h3&gt;

&lt;p&gt;Neither framework does anything magic here; both just give you a place to put the JSON-LD and the robots directives correctly. Next.js's &lt;code&gt;robots.ts&lt;/code&gt; and &lt;code&gt;sitemap.ts&lt;/code&gt; file conventions and Astro's config-driven equivalents both compile to the same static output Google reads. The mistake that actually costs rankings is not a framework limitation, it is a malformed JSON-LD block or a canonical pointing at the wrong URL shipping quietly because nobody checked the built HTML before merge.&lt;/p&gt;

&lt;h2&gt;
  
  
  A decision checklist for a repo-based blog choosing between them
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;If your repo is...&lt;/th&gt;
&lt;th&gt;Lean toward&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A blog, docs site, or marketing site with no logged-in area&lt;/td&gt;
&lt;td&gt;Astro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A blog that will always stay separate from any app/dashboard&lt;/td&gt;
&lt;td&gt;Astro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A blog living inside a product repo that already has auth, a dashboard, or Server Actions&lt;/td&gt;
&lt;td&gt;Next.js&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Planning to add personalization, checkout, or user accounts later&lt;/td&gt;
&lt;td&gt;Next.js&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Only need one or two personalized widgets on otherwise static pages&lt;/td&gt;
&lt;td&gt;Either: Astro Server Islands or Next.js Suspense both cover this&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migrating an existing Next.js app and adding a blog to it&lt;/td&gt;
&lt;td&gt;Next.js, don't split frameworks for one section&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We ran this exact checklist on our own site. Trylyra.ai is a marketing page plus a blog with no auth and no dashboard, the textbook case for Astro. We built it on Next.js's App Router anyway, because the same repo also needs to grow into the product dashboard later, and splitting frameworks for one section was not worth maintaining two build pipelines for a team our size. That is the trade-off this checklist is for: it is not always the framework with the better default score, it is the one that matches what the rest of your repo already is.&lt;/p&gt;

&lt;p&gt;If none of the app-like requirements apply, default to Astro and take the Core Web Vitals win with less engineering effort. If the blog shares a repo with a product that already needs auth or a dashboard, Next.js's App Router gets you comparable scores with one codebase instead of two. Either way, the framework choice is a one-time decision your &lt;a href="https://www.trylyra.ai/blog/seo-for-saas/" rel="noopener noreferrer"&gt;SEO strategy&lt;/a&gt; does not depend on nearly as much as the discipline you bring to canonicals, sitemaps, and shipping verified content on top of it.&lt;/p&gt;

&lt;p&gt;For teams whose blog already lives in one of these repos and just needs posts written and reviewed without babysitting the frontmatter, that is the exact slot our &lt;a href="https://www.trylyra.ai/blog/ai-blog-writer-for-developers/" rel="noopener noreferrer"&gt;AI blog writer for developers&lt;/a&gt; is built for: it reads your repo, drafts in your voice, and opens a pull request against whichever framework you picked. If you want to see how that looks against your own stack, &lt;a href="https://www.trylyra.ai/contact/" rel="noopener noreferrer"&gt;request early access&lt;/a&gt; and tell us which of the two you are on. The workflow is the same either way, a &lt;a href="https://www.trylyra.ai/blog/git-based-ai-blog-writer/" rel="noopener noreferrer"&gt;Git-based AI blog writer&lt;/a&gt; commits a &lt;code&gt;.md&lt;/code&gt; file to a branch and opens a PR, since neither Astro nor Next.js changes what a reviewable diff looks like.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm building &lt;a href="https://www.trylyra.ai/" rel="noopener noreferrer"&gt;Lyra&lt;/a&gt;, an autonomous blog writer that writes in your blog's voice, fact-checks every claim, and opens a pull request you review. This post comes from her blog, where we publish what we learn running the pipeline. Happy to answer questions in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>astro</category>
      <category>nextjs</category>
      <category>seo</category>
      <category>webdev</category>
    </item>
    <item>
      <title>llms.txt: what it is and how to add one</title>
      <dc:creator>Mitrasish</dc:creator>
      <pubDate>Tue, 07 Jul 2026 20:30:00 +0000</pubDate>
      <link>https://dev.to/mitrasish/llmstxt-what-it-is-and-how-to-add-one-4g3k</link>
      <guid>https://dev.to/mitrasish/llmstxt-what-it-is-and-how-to-add-one-4g3k</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.trylyra.ai/blog/llms-txt-guide/" rel="noopener noreferrer"&gt;the Lyra blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;An llms.txt file is a plain Markdown file you host at &lt;code&gt;/llms.txt&lt;/code&gt; that hands AI crawlers a clean, curated map of your site's most important pages. It is not styled for people. It is a shortlist a language model can read in one pass: your site name, a one-paragraph summary, and a few sections of links with short descriptions. The idea, proposed by Jeremy Howard in 2024, is simple. Models work better with context than with raw HTML, so give them the context directly.&lt;/p&gt;

&lt;p&gt;This guide covers what goes in an llms.txt file, whether it actually moves the needle, how it differs from robots.txt and a sitemap, and how to write and host one in a few minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is llms.txt and what goes in it?
&lt;/h2&gt;

&lt;p&gt;llms.txt is a Markdown file at the root of your domain that points AI models at the pages that matter, with a sentence of context on each. A browser will render it as text. A model reading it gets a quick, structured sense of what your site is and where the good parts live, without crawling and parsing every page first.&lt;/p&gt;

&lt;p&gt;The format is loose but conventional. A well-formed file has four parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An &lt;strong&gt;H1&lt;/strong&gt; with the name of your site or project. This is the only required line.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;blockquote summary&lt;/strong&gt;: two or three sentences on what the site is and who it serves.&lt;/li&gt;
&lt;li&gt;Optional &lt;strong&gt;free-text notes&lt;/strong&gt; under the summary for anything a model should know up front.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;H2 sections&lt;/strong&gt; of links, each link followed by a short description after a colon.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because it is just Markdown, it reads the same way structured content reads to a model: predictable headings, plain prose, no markup noise. That predictability is the point. The same instinct drives good &lt;a href="https://www.trylyra.ai/blog/answer-engine-optimization/" rel="noopener noreferrer"&gt;answer engine optimization&lt;/a&gt;: answer the question plainly, structure it cleanly, and machine readers pick it up. An llms.txt file applies that thinking at the level of your whole site instead of a single page.&lt;/p&gt;

&lt;p&gt;It pairs with the technical hygiene that already helps you rank. If you have worked through &lt;a href="https://www.trylyra.ai/blog/seo-for-saas/" rel="noopener noreferrer"&gt;SEO for SaaS&lt;/a&gt;, an llms.txt file is a small, fast addition to that same checklist, not a replacement for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does llms.txt actually help?
&lt;/h2&gt;

&lt;p&gt;Honest answer: it is an emerging convention, not a ranking guarantee. No major AI provider has publicly confirmed that an llms.txt file changes how they rank, retrieve, or cite your pages. Adoption among the big crawlers is still uneven, and some teams treat it with healthy skepticism for exactly that reason.&lt;/p&gt;

&lt;p&gt;So why add one? A few reasons that hold up even without a confirmed ranking benefit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is cheap.&lt;/strong&gt; Writing one takes minutes. Maintaining it means editing a text file when you ship or retire a major page. There is no infrastructure and no ongoing cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It removes ambiguity.&lt;/strong&gt; A curated shortlist with descriptions is easier for any model to parse than a 200-page sitemap or a wall of rendered HTML. You are doing the summarizing instead of hoping the crawler gets it right.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is a signal.&lt;/strong&gt; Shipping one says you think about machine readers as an audience. That mindset, more than the file itself, is what helps you show up in AI answers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treat it the way you would treat clean structured data or a tidy sitemap: good practice with a low ceiling on downside, not a magic lever. The win is not the file alone. It is the habit of writing for both people and the models that increasingly sit between people and your site. The same logic is why we &lt;a href="https://www.trylyra.ai/blog/ai-content-fact-checking/" rel="noopener noreferrer"&gt;fact-check every claim and link&lt;/a&gt; in a post before it ships: if a model is going to cite you, the page it cites had better be accurate and easy to read.&lt;/p&gt;

&lt;p&gt;If you want to see a live one, Lyra serves her own at &lt;a href="https://www.trylyra.ai/llms.txt" rel="noopener noreferrer"&gt;trylyra.ai/llms.txt&lt;/a&gt;. It is a small, real example of the format described here.&lt;/p&gt;

&lt;h2&gt;
  
  
  How is llms.txt different from robots.txt and sitemap.xml?
&lt;/h2&gt;

&lt;p&gt;They live in the same place and they all talk to crawlers, but they do different jobs. Mixing them up is the most common confusion, so here is the split.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Who it is for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;robots.txt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sets rules: which paths crawlers may or may not access.&lt;/td&gt;
&lt;td&gt;Crawlers deciding what to fetch.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sitemap.xml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Lists every URL so crawlers can discover them all.&lt;/td&gt;
&lt;td&gt;Search engines indexing the full site.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;llms.txt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Curates your best pages with plain-language descriptions.&lt;/td&gt;
&lt;td&gt;Language models that want context fast.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A sitemap is exhaustive. It wants to list everything. An llms.txt file is the opposite: it is opinionated and short, a highlight reel of the pages you would actually want an AI to read and quote. robots.txt is about permission, not content: it decides which crawlers may read you, and getting the &lt;a href="https://www.trylyra.ai/blog/robots-txt-for-ai-bots/" rel="noopener noreferrer"&gt;AI crawler rules right&lt;/a&gt; is its own job, because blocking the wrong bot quietly deletes you from AI answers. None of the three replaces the others. You can and usually should have all three.&lt;/p&gt;

&lt;p&gt;One nuance worth keeping straight. Some teams also publish full-text versions of pages at paths like &lt;code&gt;page.md&lt;/code&gt; or an &lt;code&gt;llms-full.txt&lt;/code&gt; that inlines whole documents. That is an extension, not a requirement. The core llms.txt file is just the map.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I write an llms.txt file?
&lt;/h2&gt;

&lt;p&gt;Start with the pages you would hand a new hire on day one. Your homepage, your core product or pricing pages, your docs, and a handful of your strongest blog posts. Skip thin pages, duplicates, and anything expired. Quality over coverage. A model gets more from 15 well-described links than from 150 bare URLs. Your docs pages deserve the same scrutiny as the rest of the list: they carry the highest concentration of AI agent traffic on most sites, so getting them structured for &lt;a href="https://www.trylyra.ai/blog/docs-seo-getting-your-api-documentation-cited-by-ai/" rel="noopener noreferrer"&gt;docs SEO&lt;/a&gt; matters at least as much as which links make the file.&lt;/p&gt;

&lt;p&gt;Then write the file. Here is a minimal example you can adapt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Acme Analytics&lt;/span&gt;
&lt;span class="gt"&gt;
&amp;gt; Acme Analytics is a privacy-first product analytics tool for small SaaS&lt;/span&gt;
&lt;span class="gt"&gt;&amp;gt; teams. We help founders see which features drive retention without&lt;/span&gt;
&lt;span class="gt"&gt;&amp;gt; shipping user data to third parties.&lt;/span&gt;

Self-serve, no credit card to start. Docs are public and versioned.

&lt;span class="gu"&gt;## Product&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Overview&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://acme.com/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;: What Acme does and who it is for.
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Pricing&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://acme.com/pricing/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;: Plans, limits, and the free tier.
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Integrations&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://acme.com/integrations/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;: Supported sources and SDKs.

&lt;span class="gu"&gt;## Docs&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Quickstart&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://acme.com/docs/quickstart/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;: Install and send your first event.
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;API reference&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://acme.com/docs/api/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;: Endpoints, auth, and rate limits.

&lt;span class="gu"&gt;## Blog&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Retention metrics that matter&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://acme.com/blog/retention/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;: The four numbers we track.
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Self-hosting Acme&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://acme.com/blog/self-host/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;: Run it on your own infra.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few rules that keep it useful:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use absolute URLs.&lt;/strong&gt; A model may read the file out of context, so relative paths can break.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write descriptions, not labels.&lt;/strong&gt; "Pricing: plans, limits, and the free tier" tells a model more than "Pricing" alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the summary specific.&lt;/strong&gt; Say what you do and who you serve. Skip the marketing adjectives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stay current.&lt;/strong&gt; When you ship or kill a major page, update the file. A stale map is worse than none.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do I host it at /llms.txt?
&lt;/h2&gt;

&lt;p&gt;The file has to resolve at your domain root, at &lt;code&gt;https://yourdomain.com/llms.txt&lt;/code&gt;, and return plain text or Markdown. How you get it there depends on your stack.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Static sites:&lt;/strong&gt; drop &lt;code&gt;llms.txt&lt;/code&gt; in your &lt;code&gt;public/&lt;/code&gt; or root output directory. Most static hosts serve it as-is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next.js, Astro, and similar frameworks:&lt;/strong&gt; put it in the public/static folder, or add a route that returns the text with a &lt;code&gt;text/plain&lt;/code&gt; or &lt;code&gt;text/markdown&lt;/code&gt; content type.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A CMS or platform you do not control:&lt;/strong&gt; check whether it lets you add a root-level file or a custom route. If not, a redirect or edge function can serve it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once it is live, open the URL in a browser and confirm you see your raw Markdown, not a 404 and not an HTML page wrapping it. That is the whole job. There is no registry to submit to and no verification step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits in your AI search strategy
&lt;/h2&gt;

&lt;p&gt;An llms.txt file is one small piece. It makes your site easy to map. It does not write the pages that make the map worth reading. Those still come from clear, accurate, well-structured content that answers real questions, which is what gets cited whether or not a crawler reads your llms.txt first. The file is a pointer; the pages are the payload.&lt;/p&gt;

&lt;p&gt;This is where the work scales or stalls. Maintaining the file is trivial. Producing a steady stream of pages worth pointing to is the hard part, and it is exactly the gap Lyra fills. Lyra is an autonomous writer who finds topics worth covering, drafts them in your blog's existing voice, fact-checks the claims and links, scores the draft, and opens a GitHub pull request for you to review. Nothing publishes on its own. You bring your own Anthropic key, and Lyra is in early access while we build in the open, so &lt;a href="https://www.trylyra.ai/contact/" rel="noopener noreferrer"&gt;talk to the founder&lt;/a&gt; to see whether she's a fit. If you want the deeper version of how an &lt;a href="https://dev.to/ai-blog-writer/"&gt;autonomous AI blog writer&lt;/a&gt; works end to end, the pillar page walks through it.&lt;/p&gt;

&lt;p&gt;Add the llms.txt file this afternoon. It is five minutes of work with no real downside. Then spend your energy on the content it points to, because that is what actually earns the citation.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm building &lt;a href="https://www.trylyra.ai/" rel="noopener noreferrer"&gt;Lyra&lt;/a&gt;, an autonomous blog writer that writes in your blog's voice, fact-checks every claim, and opens a pull request you review. This post comes from her blog, where we publish what we learn running the pipeline. Happy to answer questions in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>seo</category>
      <category>llm</category>
      <category>webdev</category>
    </item>
    <item>
      <title>GitHub Actions SEO: gate PRs on broken links and schema</title>
      <dc:creator>Mitrasish</dc:creator>
      <pubDate>Sun, 05 Jul 2026 20:30:00 +0000</pubDate>
      <link>https://dev.to/mitrasish/github-actions-seo-gate-prs-on-broken-links-and-schema-4eo3</link>
      <guid>https://dev.to/mitrasish/github-actions-seo-gate-prs-on-broken-links-and-schema-4eo3</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.trylyra.ai/blog/github-actions-seo-checks/" rel="noopener noreferrer"&gt;the Lyra blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Code review is good at catching logic bugs. SEO bugs are different: a broken canonical does not throw a build error, a dead external link does not fail a type check, and a malformed JSON-LD block does not appear in a diff in any way that signals a problem. They ship quietly. You find out weeks later from Search Console.&lt;/p&gt;

&lt;p&gt;The fix is a GitHub Actions SEO workflow that gates every blog PR automatically. Four jobs check broken links, meta and canonical correctness, JSON-LD validity, and a Lighthouse performance budget. The merge button stays red until all four pass.&lt;/p&gt;

&lt;p&gt;This is the workflow, job by job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a blog PR can ship that code review misses
&lt;/h2&gt;

&lt;p&gt;A code reviewer checking a blog post looks at the prose: is the structure right, does the intro land, are the claims defensible? Nobody in that review is clicking every external link, validating the canonical, or running the new hero image through a performance budget. Those checks are not part of the review process. CI makes them automatic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Broken external links nobody clicked during editorial review
&lt;/h3&gt;

&lt;p&gt;External links rot. A link that resolved when the author found the source may have moved, renamed, or 404ed by the time the post ships. Nobody in editorial review clicks every citation in a 2,000-word post. A CI job does.&lt;/p&gt;

&lt;h3&gt;
  
  
  A missing or self-conflicting canonical that splits your ranking signal
&lt;/h3&gt;

&lt;p&gt;The canonical tag tells Google which URL to credit when the same or similar content appears at multiple addresses. In a Next.js App Router site, pages generate their canonical via &lt;code&gt;generateMetadata&lt;/code&gt;. The common failure mode is a page that inherits a canonical from a parent layout instead of setting its own, producing a post whose canonical points at &lt;code&gt;/blog/&lt;/code&gt; rather than &lt;code&gt;/blog/your-post-slug/&lt;/code&gt;. Astro's sitemap integration has its own version of this failure mode, covered in our &lt;a href="https://www.trylyra.ai/blog/astro-vs-nextjs-seo/" rel="noopener noreferrer"&gt;Astro vs Next.js SEO comparison&lt;/a&gt;, so the check below is worth adapting rather than skipping if you are on Astro instead.&lt;/p&gt;

&lt;p&gt;The page renders without error, silently sending its ranking signal to the wrong URL.&lt;/p&gt;

&lt;h3&gt;
  
  
  Malformed JSON-LD that silently forfeits rich-result eligibility
&lt;/h3&gt;

&lt;p&gt;Nestlé measured that pages appearing as rich results in Google Search have an 82% higher click-through rate than non-rich-result pages, a figure cited in &lt;a href="https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data" rel="noopener noreferrer"&gt;Google's structured data documentation&lt;/a&gt;. A &lt;a href="https://blog.milestoneinternet.com/seo/seo-click-curves-get-58-clicks-per-100/" rel="noopener noreferrer"&gt;Milestone Internet study of 4.5 million queries&lt;/a&gt; measured 58 clicks per 100 queries for rich results against 41 for standard results. A single malformed property in the JSON-LD block, a date string in the wrong format, or a missing required field silently disqualifies the page from rich-result consideration. The structured data is rendered in the HTML; it just does not validate.&lt;/p&gt;

&lt;p&gt;Lighthouse runs &lt;a href="https://unlighthouse.dev/learn-lighthouse/seo" rel="noopener noreferrer"&gt;around 8 automated SEO audits per page&lt;/a&gt;, and none of them validate JSON-LD content. A separate validation step closes that gap.&lt;/p&gt;

&lt;h3&gt;
  
  
  A new hero image that blows your Lighthouse budget
&lt;/h3&gt;

&lt;p&gt;Google's Core Web Vitals thresholds are LCP under 2.5 seconds, CLS under 0.1, and INP under 200 milliseconds. Roughly half of all tracked origins pass all three, per 2025 Web Almanac data, with desktop (56%) outperforming mobile (48%).&lt;/p&gt;

&lt;p&gt;A PR that adds a 3MB PNG where a 200KB WebP should be can push LCP over threshold, but the build succeeds and the post looks fine locally. The regression only surfaces in Search Console weeks later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The GitHub Actions SEO workflow: four checks, one file
&lt;/h2&gt;

&lt;p&gt;All four jobs live in &lt;code&gt;.github/workflows/blog-seo.yml&lt;/code&gt;. The workflow triggers on pull requests that change files in &lt;code&gt;content/blog/&lt;/code&gt;, so it only runs when content changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Blog SEO checks&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;content/blog/**'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.github/workflows/blog-seo.yml'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Job 1: broken links - lychee-action scans Markdown files before the build
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/lycheeverse/lychee-action" rel="noopener noreferrer"&gt;lychee-action&lt;/a&gt; wraps lychee, a link checker written in Rust. The lychee project benchmarks it at 576 links in about 60 seconds on the analysis-tools-dev/static-analysis repository; throughput varies by repo size and link distribution, but most blogs with a few dozen posts complete in well under two minutes. It reads Markdown files directly and does not require a running server, so it can complete before any build step.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;broken-links&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Broken links&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v7&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Check links&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lycheeverse/lychee-action@v2&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;--verbose --no-progress 'content/blog/**/*.md'&lt;/span&gt;
          &lt;span class="na"&gt;fail&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;jobSummary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fail: true&lt;/code&gt; exits with a non-zero code on any broken link, which fails the job. &lt;code&gt;jobSummary: true&lt;/code&gt; writes the full report to the GitHub Actions job summary, accessible from the PR's check status.&lt;/p&gt;

&lt;p&gt;Add a &lt;code&gt;.lycheeignore&lt;/code&gt; at the repo root for URLs to exclude, one regex per line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Localhost references in code blocks
http://localhost
# Web archive links
https://web.archive.org
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Job 2: meta, canonical, and OG tags - parse built HTML after next build
&lt;/h3&gt;

&lt;p&gt;There is no off-the-shelf action for meta-tag validation on a Next.js App Router site, so this job builds the site and runs a short Node script against the HTML output. The script checks each page for a &lt;code&gt;&amp;lt;meta name="description"&amp;gt;&lt;/code&gt;, a &lt;code&gt;&amp;lt;link rel="canonical"&amp;gt;&lt;/code&gt; that matches the page's own URL, and basic Open Graph tags.&lt;/p&gt;

&lt;p&gt;After validating, the job uploads the build as an artifact. The JSON-LD and Lighthouse jobs download it instead of rebuilding, so all three validate the same output and CI time does not multiply with each additional check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;meta-tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Meta and canonical tags&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v7&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;20'&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;npm'&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Cache Next.js build&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/cache@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.next/cache&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx next build&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;NODE_ENV&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Check meta and canonical tags&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node scripts/check-meta.mjs&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Upload build artifact&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v7&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;next-build&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;.next/&lt;/span&gt;
            &lt;span class="s"&gt;public/&lt;/span&gt;
          &lt;span class="na"&gt;retention-days&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting &lt;code&gt;process.exitCode = 1&lt;/code&gt; instead of calling &lt;code&gt;process.exit(1)&lt;/code&gt; immediately lets the script report every failure across all pages in a single run rather than stopping at the first hit. Create &lt;code&gt;scripts/check-meta.mjs&lt;/code&gt; in your repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// scripts/check-meta.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readdir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;readFile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs/promises&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;join&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resolve&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:path&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SITE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SITE_URL&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://yoursite.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BLOG_DIR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.next/server/app/blog&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;withFileTypes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;full&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isDirectory&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;full&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;page.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;full&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;htmlPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;htmlPath&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;BLOG_DIR&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/page.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;htmlPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expectedUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SITE_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;meta&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+name="description"&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+content="&lt;/span&gt;&lt;span class="se"&gt;([^&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;"/i&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt;
    &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;meta&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+content="&lt;/span&gt;&lt;span class="se"&gt;([^&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+name="description"/i&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt;
    &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[FAIL] Missing meta description: /blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exitCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canonical&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;link&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+rel="canonical"&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+href="&lt;/span&gt;&lt;span class="se"&gt;([^&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;"/i&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt;
    &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;link&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+href="&lt;/span&gt;&lt;span class="se"&gt;([^&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+rel="canonical"/i&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt;
    &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;canonical&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;canonical&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;expectedUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[FAIL] Canonical mismatch: /blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`  Expected: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;expectedUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`  Found:    &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;canonical&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;missing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exitCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ogTitle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;meta&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+property="og:title"&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+content="&lt;/span&gt;&lt;span class="se"&gt;([^&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;"/i&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt;
    &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;meta&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+content="&lt;/span&gt;&lt;span class="se"&gt;([^&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+property="og:title"/i&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt;
    &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ogTitle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[FAIL] Missing og:title: /blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exitCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[OK]   /blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;BLOG_DIR&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[FAIL] No HTML found in .next/server/app/blog - run next build first&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exitCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;checkPage&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;walk&lt;/code&gt; recurses the App Router build directory and collects every &lt;code&gt;page.html&lt;/code&gt; file. Next.js 15 App Router writes pre-rendered pages to &lt;code&gt;.next/server/app/blog/&amp;lt;slug&amp;gt;/page.html&lt;/code&gt;, so the slug is extracted directly from the path. &lt;code&gt;checkPage&lt;/code&gt; reads each file, runs all three checks without short-circuiting, and logs every failure before the process exits. Set &lt;code&gt;SITE_URL&lt;/code&gt; via the environment (or hardcode your domain) to match the canonical your &lt;code&gt;generateMetadata&lt;/code&gt; produces.&lt;/p&gt;

&lt;h3&gt;
  
  
  Job 3: JSON-LD linting - schemar posts pass/fail results as a sticky PR comment
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://johnnyreilly.com/schemar-github-action-to-validate-structured-data" rel="noopener noreferrer"&gt;Schemar&lt;/a&gt; (&lt;code&gt;johnnyreilly/schemar&lt;/code&gt;) wraps the Schema Markup Validator. It accepts a list of URLs, checks the JSON-LD on each against Schema.org's rules, and returns pass/fail results. Combine it with &lt;code&gt;marocchino/sticky-pull-request-comment&lt;/code&gt; to keep the validation output as a single updating comment on the PR rather than a new comment on every push.&lt;/p&gt;

&lt;p&gt;This job downloads the build artifact from the meta-tags job rather than rebuilding from scratch. The &lt;code&gt;needs: meta-tags&lt;/code&gt; dependency controls ordering; the artifact carries the actual output.&lt;/p&gt;

&lt;p&gt;The job also needs the slug of the post being reviewed. Rather than hardcoding it, a &lt;code&gt;get-slug&lt;/code&gt; step extracts the filename from the git diff - the slug is just the new &lt;code&gt;.md&lt;/code&gt; filename in &lt;code&gt;content/blog/&lt;/code&gt; with its extension stripped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;json-ld&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;JSON-LD validation&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;meta-tags&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v7&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;20'&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;npm'&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Download build artifact&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/download-artifact@v8&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;next-build&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Get new post slug&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;slug&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;git fetch origin ${{ github.base_ref }}:refs/remotes/origin/${{ github.base_ref }}&lt;/span&gt;
          &lt;span class="s"&gt;SLUG=$(git diff --name-only origin/${{ github.base_ref }}..HEAD \&lt;/span&gt;
            &lt;span class="s"&gt;-- 'content/blog/' | grep '\.md$' | head -1 \&lt;/span&gt;
            &lt;span class="s"&gt;| sed 's|content/blog/||; s|\.md$||')&lt;/span&gt;
          &lt;span class="s"&gt;echo "slug=${SLUG}" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Start preview server&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx next start &amp;amp;&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Wait for server&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx wait-on http://localhost:3000&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Validate JSON-LD&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;schemar&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;johnnyreilly/schemar@v0.1.1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;urls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:3000/blog/${{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;steps.slug.outputs.slug&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}/"&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Format results as markdown&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;format&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/github-script@v9&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;const results = ${{ steps.schemar.outputs.results }};&lt;/span&gt;
            &lt;span class="s"&gt;const lines = results.map((r) =&amp;gt;&lt;/span&gt;
              &lt;span class="s"&gt;`${r.processedValidationResult.success ? '🟢' : '🔴'} ${r.url}: ${r.processedValidationResult.resultText}`&lt;/span&gt;
            &lt;span class="s"&gt;);&lt;/span&gt;
            &lt;span class="s"&gt;core.setOutput('comment', ['### JSON-LD validation', ...lines].join('\n'));&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Post results as sticky PR comment&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;marocchino/sticky-pull-request-comment@v3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;json-ld-validation&lt;/span&gt;
          &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.format.outputs.comment }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fetch line writes an explicit refspec, &lt;code&gt;origin/${{ github.base_ref }}:refs/remotes/origin/${{ github.base_ref }}&lt;/code&gt;, instead of a bare &lt;code&gt;git fetch origin main&lt;/code&gt;. &lt;code&gt;actions/checkout@v7&lt;/code&gt; defaults to a shallow, single-branch clone of the PR head, so a bare fetch only populates &lt;code&gt;FETCH_HEAD&lt;/code&gt; and leaves no local &lt;code&gt;origin/main&lt;/code&gt; ref for the diff to compare against. The explicit refspec creates that ref directly.&lt;/p&gt;

&lt;p&gt;It still is not enough on its own. &lt;code&gt;actions/checkout@v7&lt;/code&gt;'s default depth-1 clone fetches only the PR head commit, with no shared history to &lt;code&gt;main&lt;/code&gt; in the local repository, so &lt;code&gt;origin/main&lt;/code&gt; and &lt;code&gt;HEAD&lt;/code&gt; have no common ancestor that git can find locally. A three-dot diff (&lt;code&gt;origin/main...HEAD&lt;/code&gt;), which compares against the merge base, fails with &lt;code&gt;fatal: no merge base&lt;/code&gt; in that state. The two-dot form above (&lt;code&gt;origin/main..HEAD&lt;/code&gt;) compares the two tips directly and does not need one, so it works regardless of the checkout's fetch depth.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;header&lt;/code&gt; param on the sticky comment means each new push overwrites the previous result in place. The PR timeline stays clean.&lt;/p&gt;

&lt;p&gt;Schemar's &lt;code&gt;results&lt;/code&gt; output is &lt;code&gt;Result[]&lt;/code&gt;, a JSON array, not pre-formatted markdown: each entry carries a &lt;code&gt;url&lt;/code&gt; and a &lt;code&gt;processedValidationResult&lt;/code&gt; object with &lt;code&gt;success&lt;/code&gt; and &lt;code&gt;resultText&lt;/code&gt; fields, confirmed in &lt;a href="https://raw.githubusercontent.com/johnnyreilly/schemar/main/action.yml" rel="noopener noreferrer"&gt;schemar's &lt;code&gt;action.yml&lt;/code&gt;&lt;/a&gt;. Passing that array straight to &lt;code&gt;message&lt;/code&gt; posts raw JSON on the PR. The &lt;code&gt;actions/github-script&lt;/code&gt; step in between maps each result to a one-line pass/fail row before it reaches the sticky comment, which is the same shape johnnyreilly's own writeup of the action uses for its PR comments.&lt;/p&gt;

&lt;h3&gt;
  
  
  Job 4: Lighthouse budget - serve the build locally, assert on LCP, CLS, and INP
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/treosh/lighthouse-ci-action" rel="noopener noreferrer"&gt;treosh/lighthouse-ci-action&lt;/a&gt; runs Lighthouse CI against a locally served build and fails the job when any assertion falls below threshold.&lt;/p&gt;

&lt;p&gt;Like the JSON-LD job, this downloads the artifact rather than running another build. It also uses the same &lt;code&gt;get-slug&lt;/code&gt; step to discover the post URL from the diff, then generates &lt;code&gt;.lighthouserc.json&lt;/code&gt; on the fly so no file needs manual editing per PR:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;lighthouse&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Lighthouse budget&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;meta-tags&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v7&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;20'&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;npm'&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Download build artifact&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/download-artifact@v8&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;next-build&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Get new post slug&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;slug&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;git fetch origin ${{ github.base_ref }}:refs/remotes/origin/${{ github.base_ref }}&lt;/span&gt;
          &lt;span class="s"&gt;SLUG=$(git diff --name-only origin/${{ github.base_ref }}..HEAD \&lt;/span&gt;
            &lt;span class="s"&gt;-- 'content/blog/' | grep '\.md$' | head -1 \&lt;/span&gt;
            &lt;span class="s"&gt;| sed 's|content/blog/||; s|\.md$||')&lt;/span&gt;
          &lt;span class="s"&gt;echo "slug=${SLUG}" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Generate .lighthouserc.json&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;cat &amp;gt; .lighthouserc.json &amp;lt;&amp;lt; EOF&lt;/span&gt;
          &lt;span class="s"&gt;{&lt;/span&gt;
            &lt;span class="s"&gt;"ci": {&lt;/span&gt;
              &lt;span class="s"&gt;"collect": {&lt;/span&gt;
                &lt;span class="s"&gt;"url": ["http://localhost:3000/blog/${{ steps.slug.outputs.slug }}/"],&lt;/span&gt;
                &lt;span class="s"&gt;"startServerCommand": "npx next start",&lt;/span&gt;
                &lt;span class="s"&gt;"startServerReadyPattern": "started server"&lt;/span&gt;
              &lt;span class="s"&gt;},&lt;/span&gt;
              &lt;span class="s"&gt;"assert": {&lt;/span&gt;
                &lt;span class="s"&gt;"assertions": {&lt;/span&gt;
                  &lt;span class="s"&gt;"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],&lt;/span&gt;
                  &lt;span class="s"&gt;"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],&lt;/span&gt;
                  &lt;span class="s"&gt;"total-blocking-time": ["warn", { "maxNumericValue": 300 }]&lt;/span&gt;
                &lt;span class="s"&gt;}&lt;/span&gt;
              &lt;span class="s"&gt;}&lt;/span&gt;
            &lt;span class="s"&gt;}&lt;/span&gt;
          &lt;span class="s"&gt;}&lt;/span&gt;
          &lt;span class="s"&gt;EOF&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run Lighthouse CI&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;treosh/lighthouse-ci-action@v12&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;uploadArtifacts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;temporaryPublicStorage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;configPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.lighthouserc.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Generate .lighthouserc.json&lt;/code&gt; step uses a heredoc where &lt;code&gt;${{ steps.slug.outputs.slug }}&lt;/code&gt; is substituted by the Actions runner before the shell executes - so the generated file contains the literal slug, not a variable reference. LCP under 2500ms and CLS under 0.1 are Google's passing thresholds. Using &lt;code&gt;"error"&lt;/code&gt; rather than &lt;code&gt;"warn"&lt;/code&gt; is what causes the job to fail. Total blocking time is the closest lab-measurable proxy for INP; &lt;code&gt;"warn"&lt;/code&gt; surfaces problems without blocking the merge on what is an approximation of a field metric. Tighten or relax as the site's performance baseline becomes clearer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it into the PR so the merge button stays red
&lt;/h2&gt;

&lt;p&gt;The four jobs above produce check runs on every PR. By default, GitHub does not prevent merging when a check fails. One configuration step makes them binding.&lt;/p&gt;

&lt;h3&gt;
  
  
  Required status checks in branch protection - the one setting that makes everything above binding
&lt;/h3&gt;

&lt;p&gt;Go to repository Settings, then Branches. Add a branch protection rule for the branch content merges into, typically &lt;code&gt;main&lt;/code&gt;. Under "Require status checks to pass before merging", add all four job names:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Broken links&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Meta and canonical tags&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;JSON-LD validation&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Lighthouse budget&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With these set as required, the merge button stays disabled until all four pass. A single failure keeps the PR locked regardless of approvals. The same branch protection rule is also what stops any GitHub App, including a well-behaved AI writer, from pushing straight to &lt;code&gt;main&lt;/code&gt;: it forces a Contents-write token through this exact PR path, which is worth checking alongside &lt;a href="https://www.trylyra.ai/blog/github-app-permissions/" rel="noopener noreferrer"&gt;the app's actual permission grant&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Without this step, the entire setup is advisory: the checks run and report, but nothing actually blocks the merge. This is the step most workflow tutorials omit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Surfacing failures inline with sticky PR comments
&lt;/h3&gt;

&lt;p&gt;The Schemar job's sticky comment puts JSON-LD results directly on the PR without navigating to the Actions run page. For the other three jobs, the GitHub job summary (via &lt;code&gt;jobSummary: true&lt;/code&gt; on lychee, and console output on the meta-tag script) provides the detailed report accessible from each check status link.&lt;/p&gt;

&lt;p&gt;Make the meta-tag script output specific enough to act on immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[FAIL] Missing meta description: /blog/new-post-slug/
[FAIL] Canonical mismatch: /blog/new-post-slug/
  Expected: https://yoursite.com/blog/new-post-slug/
  Found:    https://yoursite.com/blog/
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Keeping it zero-maintenance: .lycheeignore, pinned action versions, and caching the build
&lt;/h3&gt;

&lt;p&gt;Three habits prevent the workflow from becoming a source of noise.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pin action versions to major version tags (&lt;code&gt;@v2&lt;/code&gt;, &lt;code&gt;@v7&lt;/code&gt;, &lt;code&gt;@v12&lt;/code&gt;). Moving tags like &lt;code&gt;@latest&lt;/code&gt; break without warning when upstream ships a breaking change. Check release pages when onboarding a new action; &lt;code&gt;marocchino/sticky-pull-request-comment&lt;/code&gt; is at v3, for example.&lt;/li&gt;
&lt;li&gt;Share the build output. The cache step in the meta-tags job preserves &lt;code&gt;.next/cache&lt;/code&gt; between workflow runs, and the artifact upload carries the final output to the JSON-LD and Lighthouse jobs - one build per PR, three jobs consuming it.&lt;/li&gt;
&lt;li&gt;Keep &lt;code&gt;.lycheeignore&lt;/code&gt; current. As the blog grows, more code-block URLs and archived-page references need exclusion. A stale file generates false failures that train the team to dismiss CI output; update it when adding an exclusion-worthy URL.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where the green check ends and editorial judgment begins
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What four passing jobs actually confirm - and what they cannot
&lt;/h3&gt;

&lt;p&gt;A green run confirms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No external link in the PR's Markdown files returns a 4xx or 5xx response&lt;/li&gt;
&lt;li&gt;Every generated page has a meta description, a self-referencing canonical, and Open Graph tags&lt;/li&gt;
&lt;li&gt;The structured data on the new post validates against Schema.org&lt;/li&gt;
&lt;li&gt;The new post clears Core Web Vitals thresholds under lab conditions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it does not confirm: whether the facts are correct, whether the post answers the question it sets up, or whether the prose is worth reading. CI has no opinion about those things.&lt;/p&gt;

&lt;p&gt;This is the same division that makes &lt;a href="https://www.trylyra.ai/blog/automated-content-creation/" rel="noopener noreferrer"&gt;automated content creation&lt;/a&gt; work without creating editorial risk: automate every check that has a clear pass/fail definition, leave judgment to people with context.&lt;/p&gt;

&lt;h3&gt;
  
  
  The split that works: CI owns technical correctness, humans own voice and facts
&lt;/h3&gt;

&lt;p&gt;When a PR reaches human review with all four checks green, the reviewer does not need to wonder whether the canonical is pointing at itself or whether the link to the case study still resolves. CI answered those questions. The reviewer can focus on what CI cannot check: accuracy, voice, and whether the post actually serves the reader.&lt;/p&gt;

&lt;p&gt;Combined with an earlier fact-checking step, &lt;a href="https://www.trylyra.ai/blog/ai-content-fact-checking/" rel="noopener noreferrer"&gt;Lyra verifies claims and links before opening the PR&lt;/a&gt;, CI gates the technical surface, and human review handles editorial judgment. All three pass before the post ships.&lt;/p&gt;

&lt;p&gt;For teams using a &lt;a href="https://www.trylyra.ai/blog/ai-blog-writer-for-developers/" rel="noopener noreferrer"&gt;PR-based AI blog writer&lt;/a&gt; where an agent produces the first draft, the CI gate is especially useful. The agent drafts fast, the checks run in parallel, and the reviewer sees a PR already validated on both the technical and factual axes. &lt;a href="https://www.trylyra.ai/blog/internal-linking-automation/" rel="noopener noreferrer"&gt;Internal linking automation&lt;/a&gt; also benefits directly: the broken-link job confirms that any new cross-links added to a post actually resolve before they ship.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm building &lt;a href="https://www.trylyra.ai/" rel="noopener noreferrer"&gt;Lyra&lt;/a&gt;, an autonomous blog writer that writes in your blog's voice, fact-checks every claim, and opens a pull request you review. This post comes from her blog, where we publish what we learn running the pipeline. Happy to answer questions in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>github</category>
      <category>seo</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
