A finding is a complaint. A ticket is a change.
Most SEO recommendations reach engineering as findings. "Canonical tags are inconsistent." "Schema markup is missing." "Page speed needs improvement." Every one of those can be true, and none of them is work. A developer reading them has to redo the research the SEO already did, guess at the intent, and carry the risk of guessing wrong.
So they don't. The ticket gets a label, moves to the backlog, and stays there. Six months later the agency's slide says "blocked by dev" and the client's engineering lead says "they never told us what they wanted". Both are describing the same document.
We write these tickets every week, because our technical retainer is defined as ending in a merged pull request rather than a PDF. That means we also get them wrong in public, in front of people who review code for a living. What follows is the format we settled on. It isn't clever. It's just finished.
The distinction doing all the work: a finding describes a state of the world; a ticket describes a change to a named thing. If your ticket doesn't name a file, a template or a config key, it's still a finding wearing a ticket's clothes.
Why findings-shaped tickets rot in a backlog
It helps to be precise about the failure, because "developers deprioritise SEO" is the wrong diagnosis and leads to the wrong fix — usually escalation, which makes it worse. A findings-shaped ticket fails four separate process gates before anyone gets to decide whether they care about it.
- It can't be estimated. "Add schema markup" is forty minutes or three weeks depending on how many types, which templates, and whether the underlying data even exists in the CMS. A ticket nobody can size doesn't go into a sprint, because sprints are built out of sized things.
- It can't be assigned. Frontend, backend or content? An ambiguous ticket has no obvious owner, and unowned tickets are precisely the ones that survive every round of backlog grooming untouched.
- It can't be reviewed. The person approving the pull request has no way to judge whether the change is right. Faced with a diff they can't evaluate, most reviewers stall rather than approve — which is correct behaviour, and fatal to your timeline.
- It can't be closed. Nobody closes a ticket that might come back. "Improve internal linking" has no terminal state, so it stays open, and after a while people stop reading the column it lives in.
None of this is resistance
We've had this argument with engineering leads who wanted the fixes more than we did, and who were visibly annoyed that they couldn't act on them. The blocker was a document that asked a question instead of describing a change. Rewrite the same recommendation as eight fields and the same team ships it in the next sprint, with no change in enthusiasm on either side.
The template, field by field
Eight fields. A properly written one takes about fifteen minutes, which is the real cost of this method and the reason people skip it. Fifteen minutes against a ticket that would otherwise sit for a quarter is not a hard trade to make.
| Field | What goes in it | What breaks without it |
|---|---|---|
| Title | The change, in the imperative, naming the thing changed. "Emit a self-referencing canonical on all /products/* pages." | Triage happens on titles alone. Yours reads as a research task and gets sorted like one. |
| Why now | Two sentences of business consequence, with a number if you have one. Not the SEO theory. | It loses every prioritisation call against a bug with a customer's name attached. |
| Files or templates | Actual paths. app/products/[slug]/page.tsx, snippets/head.liquid. A labelled guess is fine. | The developer redoes your research, or edits a different file and fixes a different page. |
| The change | The exact output. The literal tag, header, or redirect rule. Paste it, don't describe it. | You get an interpretation of your intent, and interpretations are where the second ticket comes from. |
| Acceptance criteria | Checkable statements in the reviewer's language, not yours. Two to four of them. | Nobody can approve the pull request, so it waits for you — and you're asleep. |
| Out of scope | The adjacent things you are explicitly not asking for in this ticket. | Scope creeps, the estimate triples, and the whole thing moves to next quarter. |
| Verification after deploy | What you'll run against production once it's live, and what output counts as pass. | "Done" comes to mean merged, and merged is not live. |
| Owner and blast radius | Who reviews it, and what else renders from that file. | A one-line template change ships to 400 pages that nobody thought to check. |
A worked example: the canonical that quietly 301s
Here is the same problem written both ways. The first is what an audit export produces. The second is what went into a sprint the day it was filed.
The finding we started with
"Canonical tags on product pages point at URLs that redirect." That's accurate, it's the correct diagnosis, and it's unshippable. Which product pages? Redirect to what? Is the canonical wrong, or is the redirect wrong? A developer has three plausible fixes and no way to choose between them, and picking the wrong one is worse than doing nothing.
The ticket we actually filed
- Title. Emit the canonical URL without a trailing slash on
/products/*. - Why now. Every product page currently declares a canonical that returns a 301 to the same URL minus the slash. We're telling Google to consolidate on a URL that immediately moves, across the 2,300 pages that carry the catalogue.
- Files.
app/products/[slug]/page.tsx— thealternates.canonicalvalue. Possibly alsoapp/sitemap.ts, if it builds URLs with the same helper. Guessing on the second one. - The change. The canonical must render as
https://example.com/products/<slug>— absolute, https, apex host, no trailing slash. Byte-identical to what the sitemap emits for the same page. - Acceptance criteria. For any three product URLs:
curl -s <url> | grep 'rel="canonical"'returns exactly one line, and fetching the URL inside it returnsHTTP/2 200, not301. - Out of scope. Category pages, the host-level trailing-slash rule, and the sitemap's
lastmodvalues. Three separate tickets, filed, linked, not blocking this one. - Verification after deploy. The same
curlagainst production, then one live URL Inspection to confirm the user-declared canonical matches what we intended. - Owner and blast radius. Frontend. That file renders every product page, so a mistake here is a 2,300-page mistake rather than a one-page mistake — worth a second reviewer.
Acceptance criteria a reviewer can check without knowing SEO
This is the field people get wrong, and it decides whether the pull request is approved this week or next month. The reviewer is a developer. They don't know what a canonical does, and they shouldn't have to.
The rule: every criterion must be a statement someone can make true or false with a command, a diff, or a browser tab they already have open. If checking it needs a paid tool or an opinion, it isn't a criterion — it's a request for a favour.
- Numbers, not adjectives. "Between 4 and 8" is checkable. "Enough" is a meeting.
- One command per criterion. A reviewer will run three commands. They will not work through a checklist of twelve, and you shouldn't ask them to.
- Say what must not change. "No change to the rendered
<h1>text" prevents the well-meant refactor that costs you the page you were protecting. - Paste the expected output, not just the command.
HTTP/2 301followed bylocation: https://…tells a reviewer what pass looks like without them having to ask.
| Written as a finding | Written as a criterion |
|---|---|
| Schema markup should be valid. | https://validator.schema.org reports zero errors against the rendered HTML of any /services/* page, and the name on the Service node is the same string as the visible <h1>. |
| Pages shouldn't be blocked from indexing. | curl -sI <url> | grep -i x-robots-tag returns nothing, and the rendered HTML contains no <meta name="robots" content="noindex">. |
| Improve internal linking on articles. | Every /journal/* page renders between 4 and 8 links to other /journal/* or /answers/* URLs, sourced from the related array in its data file. |
| Redirects should be implemented correctly. | Each of the 47 rows in redirects.csv returns a single 301 whose location header is the mapped target. curl -sIL shows exactly two status lines, not three. |
| Site speed needs improvement. | The /products/* template ships no render-blocking script over 20KB, and on a throttled mobile profile the LCP element on three sample URLs is the product image rather than a late-injected banner. |
What "done" means when nothing changes on screen
Most SEO changes are invisible. A canonical tag, a redirect rule, a JSON-LD block, an hreflang cluster — none of it moves a pixel. That breaks the ordinary definition of done, which on most teams is some version of "QA looked at it and it looked right".
So define done as three gates, and put all three in the ticket rather than in your head.
- Merged. The diff does what the ticket said. This is the only gate the developer owns, and it's where most SEO tickets stop — which is why so many fixes are reported as delivered and aren't live anywhere.
- Live and correct in production. Run the acceptance criteria against the production URL, not staging. Staging and production differ on exactly the things this work touches: hostname, protocol, CDN rules, redirect config, header injection.
- Re-crawled and re-read. The change exists for users the moment it deploys, and exists for search when Google fetches the page again. For a handful of URLs, a live test in Search Console's URL Inspection tool shows what Google gets today, including a screenshot of the rendered page. For a template change across thousands of pages you're watching the coverage reports over the following weeks — realistically two to six, depending on how often that template gets crawled.
Ask for the deployment date in writing
Without it, the reporting cycle and the release cycle drift apart, and by month three nobody can say which change caused which movement. A dated list of what went live and when is the cheapest attribution instrument in technical SEO, and almost nobody keeps one.
It's also why we run a re-check list rather than a completed list. Technical fixes revert: a cache layer serves the old header, a plugin update overwrites a template, a later refactor restores the helper you replaced. We've watched all three happen. Nothing catches it except checking again, so verification becomes a monthly job rather than a one-off tick.
Sizing and sequencing against a cadence you don't control
The other half of getting this work shipped has nothing to do with how the ticket reads. It's arithmetic, and it's the part agencies quietly skip when they build a plan.
If a team releases every six weeks, a 90-day engagement contains roughly two deployments of technical work. Not two tickets — two windows. So the sequencing question isn't "what matters most", it's "what fits in window one, given that window two lands in October".
- Batch by file, not by SEO category. Four tickets that all touch the root layout should be one pull request. Engineering resistance is usually about interruption cost rather than effort, and one review is far cheaper than four.
- Front-load everything that doesn't need a release. Content, internal links, metadata held in the CMS, redirects in host config, Business Profile work. Clear all of it out of the queue first so the release windows carry only what genuinely needs code. The half of SEO an agency can't do for you covers the rest of that division of labour.
- Ask what the emergency path is. Every team has one — a hotfix branch, a config flag, an edge rule — and it exists for incidents. A canonical pointing at a dead URL across the whole catalogue is an incident. Say so, in those words, and it may not have to wait six weeks.
- Size in the developer's units. "Small: one file, no new dependency, no data migration" gets planned. "Medium effort, high impact" gets ignored, because it's your estimate of your own importance.
- Sequence by dependency, not severity. Redirects before canonicals before sitemaps before schema. Get the order wrong and you ship a sitemap full of URLs that are about to move, then spend the next window undoing it.
The tickets we've stopped writing
A format this strict makes you notice how many recommendations don't survive it. If we can't write a checkable criterion and a plausible file path, the finding usually wasn't worth a sprint slot in the first place. That's a useful filter, and it has quietly shortened our backlogs.
- "Improve topical authority." No file, no criterion, no reviewer. It's a content plan, and content plans don't belong in an engineering backlog.
- Blanket "add schema to everything". We write one ticket per type per template, naming the required properties, because Google's guidelines are specific about which properties a type needs and about markup describing content that's actually visible on the page. A generic instruction reliably produces markup for content nobody can see, which is worse than shipping none.
- Micro-optimisations behind a release train. Shaving 40ms off a script is a real improvement and a bad use of the only deployment window you get this quarter.
- Anything whose acceptance criterion is a third-party score. "Get the audit tool to green" optimises for the tool. We've watched a team spend most of a sprint adding alt text to spacer images to clear a warning column that no search engine has ever read.
- "Please advise." If the ticket asks the developer a question, it isn't finished. Go and answer it.
The whole method, compressed
The deliverable isn't the recommendation. It's a document somebody can close without asking you anything. If a developer has to message you to start work, the ticket is unfinished — and unfinished tickets are how a technical SEO budget turns into a year of screenshots.
Everything else here is detail. Name the file, paste the output, write two criteria a stranger can check, and say what happens after deploy. That's the job.