Publishing a blog post by pull request means the post is a markdown file in your app repo: you branch, commit the file, open a PR, and CI plus a preview deploy review it before anything reaches readers. Merging is the publish action — Vercel's docs state that "pushing or merging changes into your production branch (commonly main) triggers a production deployment," and Netlify says the same: "When you merge your pull request into your production branch, Netlify publishes your changes to your production site for you." The gate costs almost nothing to build. On Astro your content-collection schema is already a build-time frontmatter check; on Next.js you have to pick a content layer first, because @next/mdx does not support frontmatter by default.
That asymmetry is the part most "content as code" write-ups skip. Here's the whole workflow, framework by framework, with the checks worth wiring and the three ways this setup can quietly lock you out of your own repo.
The loop is short enough to hold in your head:
main — post/pull-request-publishing.The preview is the part that changes how review feels. Vercel creates a preview deployment when you push a commit to a non-production branch or open a PR on GitHub, GitLab or Bitbucket; each gets a generated URL, and links show up in your Git provider's PR comments. Two URL flavours exist: a branch-specific URL that always points at the latest commit on that branch, and a commit-specific URL pinned to one build. Netlify builds Deploy Previews on the same trigger, serving PR #42 at deploy-preview-42--mysitename.netlify.app, plus a per-deploy permalink whose contents "never change, even after you redeploy your site."
Not on either platform? rossjrw/pr-preview-action publishes each PR into a GitHub Pages subdirectory, leaves a sticky comment with the link, and cleans up when the PR closes — though it doesn't support PRs from forks. Astro's own docs site uses Cloudflare Workers previews instead, and says so in its contributing guide: "Every pull request generates a preview of the docs site, including your proposed changes, using Cloudflare Workers for anyone to see."
Lee Robinson described the appeal after moving cursor.com off a headless CMS into markdown in the repo in December 2025 — three days, $260.32 in tokens, 344 agent requests, 67 commits: "When the content is code, you can create a PR, get a link with your changes, and share it with anyone. No login is required." No CMS account for the reviewer, no draft-mode cookie. A URL.
The branch and the PR don't have to be opened by a human, either — it's one REST call, POST /repos/{owner}/{repo}/pulls with head, base and title. That's how Magic Share publishes to a static-site repo: the agent opens a GitHub PR and merging it is the final publish step, so nothing lands without a human. Netlify has formalised the same shape at the platform level — an agent run that changes files "creates a Deploy Preview so you can review the results before you ship them," with an option to open or update a PR.
Astro content collections validate frontmatter with Zod, and the docs are explicit: "Schemas enforce consistent frontmatter or entry data within a collection through Zod validation." A schema that encodes your SEO rules is about fifteen lines:
// src/content.config.ts
import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";
const blog = defineCollection({
loader: glob({ base: "./src/content/blog", pattern: "**/*.{md,mdx}" }),
schema: z.object({
title: z.string().min(20).max(60),
description: z.string().min(140).max(155),
slug: z.string().regex(/^[a-z0-9-]+$/, { error: "Slug must be kebab-case." }),
pubDate: z.coerce.date(),
draft: z.boolean().default(false),
tags: z.array(z.string()).min(1),
}),
});
export const collections = { blog };
A frontmatter violation is not a warning you can ignore — it's a named build error, Content collection frontmatter invalid, rendered as Could not parse frontmatter in [collection] → [file] with messages like "title is required" or "date must be a valid date." A 68-character title never reaches your preview, let alone production.
Because it's Zod, every rule you'd otherwise hand-write is one method: .min(), .max(), .length(), .regex(), plus .refine() / .superRefine() with custom messages for anything else — rejecting a slug that doesn't match its filename, say, or a description that repeats the title verbatim.
For CI, astro check does the rest. It "runs diagnostics (such as type-checking within .astro files) against your project and reports errors to the console," takes --minimumSeverity (error / warning / hint) and --watch, and — the line that matters for a status check — exits with a code of 1 if any errors are found. astro dev, astro build and astro check all run astro sync first to generate collection types, so a broken schema surfaces in dev too.
One timeliness warning. Astro 6 shipped on 10 March 2026, and tutorials written before then have wrong code. The v6 upgrade guide states that "Astro v6.0 removes this automatic legacy content collections support, along with the legacy.collections flag. All content collections must now use the Content Layer API introduced in Astro v5.0." Concretely: the config moved to src/content.config.ts (the old path throws LegacyContentConfigError), every collection requires a loader, entry id is a slug rather than a filename, entry.render() becomes render() imported from astro:content, and Zod 4 now powers schema validation — import z from astro/zod, with custom messages as { error: "..." } rather than { message: "..." }.
Next.js gives you markdown rendering and no validation. The docs (v16.2.12, updated 23 June 2026) say it outright: "@next/mdx does not support frontmatter by default," and point you at remark-frontmatter, remark-mdx-frontmatter or gray-matter. The built-in alternative is exporting a metadata object from inside the MDX file — that works, but it's a JavaScript export, so nothing stops title from being a 200-character string.
So the first decision is the content layer, and the landscape shifted. Contentlayer, which most older Next.js blog tutorials assume, is unmaintained. Its README opens with "⚠️ Unfortunately Contentlayer is no longer maintained due to lack of funding," pointing users to a community fork. Two live options:
Either way you land where Astro starts: a Zod schema that fails the build on bad frontmatter. If you'd rather not adopt a content layer at all, validate in CI instead with mheap/frontmatter-json-schema-action, which takes a paths glob plus an inline schema or a schema_path:
name: content checks
on:
pull_request:
types: [opened, reopened, synchronize]
jobs:
frontmatter:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: mheap/frontmatter-json-schema-action@main
with:
paths: "content/blog/**/*.mdx"
schema_path: .github/post.schema.json
One Next.js-specific footgun if you plan to lint markdown through the build: with Turbopack, "remark and rehype plugins without serializable options cannot be used yet… because JavaScript functions can't be passed to Rust." Keep prose linting in CI rather than the bundler and you sidestep it.
Not every check earns a blocking status. GitHub's own docs repo splits them, and it's the right model: their content linter is markdownlint plus custom rules — GHD012 frontmatter-schema ("Frontmatter must conform to the schema"), GHD033 for alt text between 40 and 150 characters — with an explicit severity split: "Errors must be addressed before merging your changes to the main branch. Warnings should be addressed but do not prevent a change from being merged."
| Check | Tool | Verdict |
|---|---|---|
| Frontmatter + metadata lengths | Astro Zod schema, Velite/Content Collections, or frontmatter-json-schema-action |
Blocking |
| Internal links resolve | lycheeverse/lychee-action scoped to relative paths |
Blocking |
| Markdown structure | DavidAnson/markdownlint-cli2-action@v24 |
Blocking |
| Prose style | vale-cli/vale-action@v2.1.1 |
Warn |
| External links resolve | lychee, nightly schedule | Non-blocking |
markdownlint-cli2-action takes globs: '**/*.md', an optional config pointing at .markdownlint.json, and fix: true to repair what it can; it fails the job by default. The official Vale action applies style guides like Microsoft, Google or write-good — and its fail_on_error defaults to false, so Vale annotates rather than blocks. That default is right for prose: house style is advice, not a compile error.
Split link checking by direction. lychee checks links in Markdown, HTML and text, fail defaults to true, .lycheeignore holds one regex per line, and the docs recommend --cache --max-cache-age 1d to avoid 429s when you re-check the same external hosts on every push. Hard-fail on internal links; run external checks nightly and file an issue from the results, so a rate-limited host never blocks a merge.
Is checking external links worth it at all? Pew Research Center sampled just under a million pages from Common Crawl (about 90,000 per year, 2013–2023, snapshot October 2023) and found a quarter of all webpages that existed at some point in that decade were no longer accessible, including 38% of the pages that existed in 2013. Even 8% of 2023 pages were already gone. Your citations rot at roughly that rate whether or not you're watching.
An SEO-literate reader will push back here: why gate on a 60-character title when Google rewrites titles anyway? Fair, and the data half-agrees. Zyppy's study of 80,959 title tags across 2,370 sites (Q1 2022) found Google rewrote 61.6% of them. Ahrefs, studying 20,000 keywords across 192,656 unique pages, found meta descriptions rewritten 62.78% of the time — and length barely moved the needle: 61.46% for over-long descriptions versus 63.69% for in-limit ones. Google's own documentation says "there's no limit on how long a <title> element can be"; the title link is truncated to fit the device width.
So the length check is not a ranking lever. It's a rendering-and-consistency lever, and it pays for itself twice. First, Zyppy's rewrite rate by length has a floor: titles of 51–60 characters were rewritten 39–42% of the time — the lowest band measured — against more than 76% above 60 characters and 99.9% at 70+. Staying in that band improves the odds your own words are displayed. Second, a schema check stops the genuinely broken cases from shipping: an empty description, a 200-character title, a missing slug, a pubDate that isn't a date. Ahrefs found 25.02% of top-ranking pages had no meta description at all — the exact failure a build error catches, for one line of Zod.
Where those checks sit relative to drafting matters too. Magic Share runs schema validation and confirms every cited URL resolves before a draft is saved, with the verification report attached to each draft, so CI becomes a second net rather than the first line of defence.
This is the honest weak point of the workflow. Writing about the friction in March 2026, Ahmed Sabbour put it well: the source diff "is cluttered with formatting syntax," while GitHub's rich diff — great for reading rendered markdown — lacks "line numbers, comment indicators, and any connection back to specific source lines," leaving reviewers with a "hunt-and-scroll process." GitHub's own docs concede the trade-off: source view enables "line linking, which is not possible when viewing rendered Markdown files."
Four mitigations, in order of impact:
Write one sentence per line. Semantic linefeeds are the single biggest quality-of-life change: without them, a two-word edit re-flows the paragraph and the diff marks the whole block as changed; with them, only the changed sentence lights up. The idea goes back to Brian Kernighan in 1974: "Start each sentence on a new line. Make lines short, and break lines at natural places, such as after commas and semicolons, rather than randomly."
Review prose on the preview, frontmatter in the diff. Read the post as a reader on the preview URL; use the diff for the things a diff is good at — metadata, links, structural changes.
Use suggested changes for wording. A reviewer proposes exact text, the author clicks Commit suggestion, or batches several with "Add suggestion to batch" → "Commit suggestions" for one tidy commit; everyone who suggested a change becomes a co-author. Copy-editing without anyone checking out a branch.
Comment where the post is rendered. Vercel Comments are "enabled by default on all preview deployments, for all account plans, free of charge" — every commenter needs a Vercel account, and inviting external users is a Pro/Enterprise feature. Netlify's equivalent is the Netlify Drawer, where your team can "leave comments, take screenshots and video, and test responsiveness on a mobile device."
If more than one person writes, CODEOWNERS routes review by path — /src/content/blog/ @your-editor in .github/CODEOWNERS auto-requests the right reviewer for post PRs and nobody else for code PRs. Three limits: the file must be under 3 MB or it silently stops loading (taking code-owner review with it), ! negation and [ ] ranges aren't supported, and code owners aren't requested on draft PRs until you mark them ready.
GitHub rulesets offer "Require a pull request before merging," "Required approvals," "Dismiss stale pull request approvals when new commits are pushed," "Require review from Code Owners," "Require status checks to pass before merging," and a strict mode requiring the branch to be up to date with base. Three of those have sharp edges for a blog.
Solo blogs should require checks, not approvals. GitHub is unambiguous: "Pull request authors cannot approve their own pull requests." Set required approvals to 1 on a one-person repo and you've built a lock with the key inside — admins can still merge without an approving review, but you'll be overriding your own gate on every post. The recipe that works solo: require a pull request, require status checks, zero required approvals. The value was never the rubber stamp; it's the build failing on bad frontmatter and the preview URL in the PR comment.
Auto-merge turns "merge is the publish button" into "publish when green." Auto-merge "merges a pull request automatically after all required reviews and status checks pass." It needs branch protection configured and write permission to enable, and switches off if someone without write permission pushes to the head branch.
Path filters can deadlock a PR. If a required check only runs on: pull_request: paths: ['src/content/**'], a code-only PR never runs it — and per GitHub's workflow syntax reference, "if a workflow is skipped due to branch filtering, path filtering, or a commit message, then checks associated with that workflow will remain in a 'Pending' state. A pull request that requires those checks to be successful will be blocked from merging." Either run the job on every PR and let it exit early, or don't mark the path-filtered job as required.
Two costs worth pricing first. CI is close to free: GitHub Actions usage is free for public repositories and for self-hosted runners; private repos get 2,000 minutes a month on Free and 3,000 on Pro and Team, with Linux 2-core overage at $0.006 per minute. A markdown lint and a link check are a minute or two per PR.
Preview visibility is the other. Vercel sets X-Robots-Tag: noindex on preview deployments by default, with one exception: "if you assign a custom domain to a non-production branch… X-Robots-Tag: noindex will not be set." Netlify's Deploy Previews docs don't state a default either way, so check yours rather than assume — curl -I <preview-url> settles it in a second.
A merge publishes immediately, which is fine until you want a post to appear on Tuesday morning. Two patterns work.
Future-date and rebuild. Keep draft and pubDate in frontmatter, centralise an isPublished() helper that returns true only when draft: false and pubDate is in the past, and trigger a daily rebuild — on Netlify, a scheduled function that POSTs to your build hook, with schedule = "45 7 * * *" in netlify.toml. Three caveats from that write-up: use midnight-UTC timestamps, because offsets make timing unpredictable; builds take a minute or two, so a 07:45 cron publishes between 07:45 and roughly 08:00 UTC — day-level scheduling is reliable, minute-level isn't; and use a strict variant of the helper for RSS and sitemap generation so future posts never leak into feeds. Treat the build hook as a secret: anyone with the URL can trigger a build with one curl.
Or use two PRs. That's how Kubernetes runs its blog, the most complete public example of blog-as-pull-request. Posts land in /content/en/blog/_posts/YYYY/, frontmatter requires layout: blog, title and draft: true, and the instruction is explicit: don't specify a date or a date placeholder in the first PR. Review roles are named — a Writing Buddy reads for clarity, a Blog Editor approves. A second PR sets date: YYYY-MM-DD, and on the scheduled day automation triggers a build.
And the part that beats any CMS: unpublishing is a button. GitHub's Revert button on a merged PR "creates a new pull request that reverts the original merge commit." Merge that and production redeploys without the post. You need write permission, the original PR must be merged, and a conflicting revert has to be done manually — but the common case is two clicks and a deploy, with the whole history in git log.
Isn't this a git-based CMS with extra steps? If every author already lives in the repo, a CMS layer adds a second auth system without adding a capability. If someone who doesn't write code needs to publish, it earns its keep — and it sits on the same PR flow. Decap CMS's editorial workflow maps the states onto git directly: "Save draft: Commits to a new branch… and opens a pull request"; editing pushes another commit to that PR; "Approve and publish: Merges pull request and deletes branch." Keystatic is the other git-backed option, with guides for Astro, Next.js and Remix. Cursor went the other way entirely: rather than adding a CMS for the marketing team, they moved the marketing team onto GitHub.
Do I need CI to publish a blog post by pull request? No. The minimum version is a branch, a PR and a preview deploy — Vercel and Netlify both create one when you open a PR, and merging into your production branch publishes. CI upgrades review from "looks fine" to "provably has a valid title, description, slug and working internal links."
How do I check meta title and description length in CI?
On Astro, put it in the collection schema: title: z.string().max(60) and description: z.string().min(140).max(155) in src/content.config.ts — a violation is the build error "Content collection frontmatter invalid," and astro check exits with code 1, so it works as a required status check. On Next.js, adopt Velite or Content Collections for the same Zod validation, or validate frontmatter against a JSON Schema in a GitHub Action.
Are preview deployments indexed by Google?
On Vercel, previews are served with X-Robots-Tag: noindex by default — with one documented exception: assign a custom domain to a non-production branch and the header is not set. Netlify's docs don't state a default, so verify yours with curl -I <preview-url>.
Can a solo developer require review on their own blog PRs? Not as an approval — GitHub blocks pull request authors from approving their own pull requests, so a required-approvals rule on a one-person repo blocks every merge. Require a pull request and status checks with zero required approvals; the checks and the preview do the reviewing.
What if I publish something wrong? Open the merged PR and click Revert. GitHub creates a new pull request reverting the merge commit; merging it redeploys production without the post. It needs write permission, only works on merged PRs, and a conflicting revert must be done by hand.
A blog post in your repo inherits everything you already built for your app: a preview URL per change, checks that fail loudly, a review surface, a merge as the single publish action, and a one-click revert with full history. Setup is a schema file and one workflow; the recurring cost is a minute of CI per post.
What it doesn't solve is the other half — writing the post, checking every citation, and remembering to do it again next week. That's the half Magic Share's auto-drafting agent handles: it wakes at 06:00 UTC, drafts a post with schema-validated frontmatter and every cited link verified, then opens a PR against your static-site repo and waits. Drafts grow in the greenhouse; your merge moves them into the garden. If you want this review gate with the drafting already done, join the waitlist and see plans first — your first three posts are free.
For more on keeping automated publishing on the right side of search guidelines, read our close reading of Google's scaled content abuse policy and what it actually bans, or browse the rest of the notes from the garden.
Magic Share researches, writes and fact-checks posts like this for any site — point it at your URL and review your first draft today.
Plant your first post