Fixing what the audit found: the TasteBoulder work plan
Private deliverable. Enter the hub password to continue.
That's not right. Try again.
Fixing what the audit found: the TasteBoulder work plan
Nine jobs, in order. Two are one-line changes shipping today. One is a correction to what I told you this morning, because the codebase had already solved the problem better than my advice did.
⚠️ Job 8 is superseded. Later on 2026-08-08 Annette settled the Boulder topic boundaries charter, which makes topic ownership binding across her five Boulder sites. It does not name the Farmers Market, but tasteboulder never publishes attractions or events and boulderthingstodo owns place, event and attraction intent, so the Farmers Market cluster (8,100/mo) is boulderthingstodo's, not TasteBoulder's. Build it there. Everything else in this plan still stands, and the charter independently agrees with the "do not chase boulder weather" call in Job 9's what-to-ignore list.
August 8, 2026 · Companion to the tasteboulder.com SEO audit
Every finding from this morning's audit, turned into a job you can hand to a coding session. Each one names the file, the change, who does it, how long it takes, and the test that proves it worked. Nothing here says "improve" or "optimize" without saying exactly what to type.
Read the correction first. It changes the biggest item on the list.
First, a correction to the audit
The audit told you to retitle the dish pages and the top twelve cuisine pages to the "Best X in Boulder" pattern. That advice was too coarse, and following it literally would have made things worse.
Here is what I missed. There are three page families on the site that can all answer a "best X in Boulder" query: /dish/, /cuisines/, and /best/. Retitling two of them to the same phrasing puts two of your own pages on the same query. For eleven queries that is exactly what would have happened:
| Query | Monthly volume | Pages that would have competed |
|---|---|---|
| best pizza in boulder | 590 | /dish/pizza/ vs /cuisines/pizza-restaurant/ vs /cuisines/pizza-delivery/ |
| best sushi in boulder | 320 | /dish/sushi-rolls/ vs /cuisines/sushi-restaurant/ |
| boulder breweries | 210 | /cuisines/brewery/ vs /dish/ipa/ |
| best sandwiches boulder | 170 | /dish/sandwiches/ vs /cuisines/sandwich-shop/ vs /cuisines/deli/ |
| best thai food boulder | 170 | /cuisines/thai-restaurant/ vs /dish/pad-thai/ |
| best indian food boulder | 140 | /cuisines/indian-restaurant/ vs /dish/curry/ |
| best ramen boulder | 110 | /dish/ramen/ vs /cuisines/japanese-restaurant/ |
| best steakhouse boulder | 110 | /dish/steak/ vs the Steak house label in cuisines |
| best ice cream boulder | 90 | /dish/ice-cream/ vs /cuisines/ice-cream-shop/ |
| best tacos in boulder | 40 | /dish/tacos/ vs /cuisines/taco-restaurant/ |
| best burrito in boulder | 30 | /dish/burritos/ vs /cuisines/mexican-restaurant/ |
About 1,980 searches a month sit on queries where a blanket retitle would have put two of your pages against each other.
Worse, whoever built this had already worked it out and left a warning. src/pages/cuisines/[slug].astro carries this comment, verbatim:
Do not retitle this page to a "best breakfast" headline. That is the cannibalisation, and it is easy to reintroduce while writing something that reads perfectly well.
They hit this with breakfast, solved it, and wrote the trap down. My advice would have walked straight into it eleven more times. The fix below uses the mechanism they already built rather than inventing a second one.
The underlying finding stands and is unchanged: the dish pages are aimed at a query with zero measured demand, and "best X in Boulder" has roughly 2,900 a month across the set. Only the execution changes.
The ownership rule
One page owns one query. The other pages link up to it instead of competing. The codebase already has the mechanism: the GUIDES map in src/pages/cuisines/[slug].astro, which currently holds one entry (breakfast) and prints a "looking for the best breakfast rather than this category?" note that links to the money page.
Assign ownership by which page actually has the better answer:
Dish-shaped queries go to /dish/. The dish page has real prices from real menus across dozens of venues. /cuisines/pizza-restaurant/ is a list of businesses Google filed under a category label. For "best pizza in boulder", the priced list of 49 places is the better page, and it is also the one nobody else in the Boulder results has.
Cuisine-shaped queries go to /cuisines/. "Best mexican food boulder" (320/mo) and "best italian boulder" (170/mo) are asking about a kind of restaurant, not a dish. There is no dish page competing, so these retitle cleanly with no deconfliction needed.
Meal, diet and attribute queries go to /best/. Breakfast, brunch, vegan options today. This is the strongest template on the site and it should get more instances.
Job 1. Fix the boulderthingstodo.com sitemap pointer
🔴 Critical · 2 minutes · Ship today
BoulderThingsToDo/public/robots.txt says:
Sitemap: https://boulderthingstodo.com/sitemap.xml
That URL returns 404. The site uses @astrojs/sitemap (confirmed in astro.config.mjs), which emits sitemap-index.xml and sitemap-0.xml. It has never emitted sitemap.xml, so this line has pointed nowhere since launch.
Change:
Sitemap: https://boulderthingstodo.com/sitemap-index.xml
Then check every other site for the same mistake, because it is the kind of thing that gets copied between projects. Any site whose astro.config.mjs includes the sitemap() integration emits sitemap-index.xml; any site that writes its own sitemap (TasteBoulder does, as one flat 730-entry file) emits sitemap.xml. The robots.txt has to match which one the project actually is.
Test: fetch https://boulderthingstodo.com/robots.txt, follow the Sitemap line, confirm it returns 200 and contains URLs.
Job 2. Cut the homepage title to 60 characters
🟢 Trivial · 2 minutes · Ship today
TasteBoulder/src/pages/index.astro, line 203:
title="Best Restaurants in Boulder, Colorado (2026): Where Locals Actually Eat"
That is 71 characters and Google truncates around 60, so the payoff phrase never renders. Change to:
title="Best Restaurants in Boulder, Colorado (2026) | Taste Boulder"
59 characters. Move "where locals actually eat" into the meta description, which is currently at 149 of roughly 160 available and can absorb it with a small trim.
Test: character count under 60, and the rendered <title> in the deployed HTML.
Job 3. Verify five sites in Search Console
🔴 Critical · 20 minutes · Yours, not mine
No property exists for tasteboulder.com, boulderthingstodo.com, outdoorboulder.com, newtoboulder.com or medspasboulder.com. Twenty properties are verified on [email protected]; these five are not among them.
This one is yours because it needs a browser login I should not be driving. Add each as a domain property (not URL-prefix), which covers every subdomain and protocol and gives the most complete picture. Verification is a DNS TXT record, and all five domains are on Cloudflare, so it is the same five-minute loop each time.
To be explicit about the line you drew on July 27: verifying a property is not submitting a site to a search engine. It turns reporting on. It pushes nothing. Sitemap submission and IndexNow stay separate decisions and stay yours to make.
Why it is urgent rather than nice-to-have. TasteBoulder published 730 URLs in a single fortnight. The most valuable thing to know in the next thirty days is how many of those Google actually keeps, and the Index Coverage report is the only place that number exists. If Google crawls 548 restaurant pages and drops 400 of them, you want to find out in week three.
Test: all five appear in the property list, and Index Coverage shows a non-zero indexed count within about a week.
Job 4. Retitle the dish pages
🔴 Critical · Half a day · The biggest single win on the list
One line does most of the work. TasteBoulder/src/pages/dish/[slug].astro, line 72:
const title = `${group.label} in Boulder: ${group.venueCount} Restaurants, Priced`;
becomes
const title = `Best ${searchLabel(group)} in Boulder (2026): ${group.venueCount} Places, Priced`;
And line 104, the H1:
heroTitle={`${group.label} in Boulder`}
becomes
heroTitle={`Best ${searchLabel(group)} in Boulder`}
Why a searchLabel() helper and not the raw label. The data labels are dish names, and some read badly in a "best" headline or do not match how people search. Sushi Rolls is the clearest case: the query is "best sushi in boulder" (320/mo), not "best sushi rolls in boulder". So add a small override map next to the template, in the same spirit as the LABELS map that already exists in the cuisines template:
// The dish label is what the item is called on menus. The search label is what
// people actually type. Mostly identical; these are the ones that are not.
const SEARCH_LABELS = {
'sushi-rolls': 'Sushi',
'ipa': 'Craft Beer',
'espresso': 'Espresso', // stays, but see Job 4b
'latte': 'Coffee',
'green-chili': 'Green Chili',
'steak': 'Steakhouses',
};
const searchLabel = (g) => SEARCH_LABELS[g.slug] ?? g.label;
Twenty-four dish pages have no cuisine twin and retitle with zero risk: apple-pie, breakfast-burrito, cheesecake, dumplings, eggs-benedict, enchiladas, fish-and-chips, french-toast, fried-chicken, fried-rice, green-chili, mac-and-cheese, margaritas, nachos, noodles, pancakes, pho, poke, quesadillas, salmon, shrimp, soup, tiramisu, tofu. Most have small volume individually. Together they are the long tail this site should own, and they cost nothing to fix because they ride the same template line.
Keep the price data exactly where it is. It stays in the meta description, the H1 sub-line, and the table. It is the reason to click and the thing no competitor has. The change is what the page claims to be, not what it contains.
Job 4b. The three dish pages that should not say "Best"
espresso, latte and ipa are ingredients of a category the site already covers elsewhere (/best-coffee-shops/, /cuisines/brewery/). Retitling them to "Best Espresso in Boulder" creates a fourth competitor for coffee queries. Either leave these three on the old pattern or, better, fold them into the pages that own those categories and 301 the URLs. My recommendation is to fold, because three thin pages competing for coffee and beer terms is the same mistake in miniature.
Test: deploy, then fetch each of the 41 dish URLs and confirm the <title> starts with "Best", is under 60 characters, and that sushi-rolls renders as "Best Sushi in Boulder". Then confirm no two live URLs on the site carry the same "Best X in Boulder" phrase.
Job 5. Deconflict the cuisine pages
🟡 High · Half a day · Do this in the same session as Job 4
Two changes to TasteBoulder/src/pages/cuisines/[slug].astro.
5a. Extend the GUIDES map. It currently holds one entry. Add one per conflicted category, pointing at whichever page won ownership. The template already renders the note and the link, so this is data entry, not new code:
const GUIDES = {
"Breakfast restaurant": { /* existing entry, unchanged */ },
"Pizza restaurant": {
href: "/dish/pizza/",
label: "best pizza in Boulder",
note: "Looking for the best pizza rather than this category? We priced pizza across 49 Boulder menus.",
},
"Pizza delivery": { href: "/dish/pizza/", label: "best pizza in Boulder", note: "..." },
"Sushi restaurant": { href: "/dish/sushi-rolls/", label: "best sushi in Boulder", note: "..." },
"Japanese restaurant": { href: "/dish/ramen/", label: "best ramen in Boulder", note: "..." },
"Sandwich shop": { href: "/dish/sandwiches/", label: "best sandwiches in Boulder", note: "..." },
"Deli": { href: "/dish/sandwiches/", label: "best sandwiches in Boulder", note: "..." },
"Taco restaurant": { href: "/dish/tacos/", label: "best tacos in Boulder", note: "..." },
"Ice cream shop": { href: "/dish/ice-cream/", label: "best ice cream in Boulder", note: "..." },
"Steak house": { href: "/dish/steak/", label: "best steakhouses in Boulder", note: "..." },
};
These pages keep their category titles, keep their counts, and link up. Exactly the breakfast pattern.
5b. Retitle only the five cuisine pages with no dish competitor. These are safe and they carry real volume:
| Page | Current title pattern | New title | Volume |
|---|---|---|---|
/cuisines/mexican-restaurant/ |
"Mexican restaurants in Boulder (32 listed)" | Best Mexican Food in Boulder (2026): 32 Places | 320 |
/cuisines/italian-restaurant/ |
"Italian restaurants in Boulder (N listed)" | Best Italian in Boulder (2026): N Places | 170 |
/cuisines/thai-restaurant/ |
"Thai restaurants in Boulder (N listed)" | Best Thai Food in Boulder (2026): N Places | 170 |
/cuisines/indian-restaurant/ |
"Indian restaurants in Boulder (N listed)" | Best Indian Food in Boulder (2026): N Places | 140 |
/cuisines/bakery/ |
"Bakeries in Boulder (N listed)" | Best Bakeries in Boulder (2026): N Places | 110 |
Mexican needs one extra step: /dish/burritos/, /dish/tacos/, /dish/enchiladas/, /dish/quesadillas/, /dish/nachos/ and /dish/green-chili/ are all Mexican dishes. Those stay on their own specific dish queries (which are small but real) and should link up to the Mexican cuisine page. That is a hub, not a conflict, as long as the cuisine page owns the broad term and the dish pages own the narrow ones.
A note on the rest of the cuisine slugs. Several are named after Google's internal categories rather than anything a person types: hamburger-restaurant, chicken-wings-restaurant, pizza-delivery, bar-grill. Nobody searches "hamburger restaurant boulder". Fix the titles and H1s now, which is free. Only change the URLs where the volume justifies a redirect, and Job 9 covers how to do that safely.
Test: for every query in the conflict table above, exactly one live page carries it in the title. Grep the built dist/ for Best Pizza in Boulder and confirm one hit.
Job 6. Build the four missing dining pages
🟡 High · Two to three days
Four queries with real demand and nothing on the site aimed at them. All four fit the /best/[slug].astro template, which is the strongest thing on the site (2,316 words, full schema stack, ranked shortlist plus complete list). Adding entries to its KINDS map is the cheapest way to build them.
6a. Vegetarian, 260/mo. The site has /best/vegan/ and nothing for vegetarian, and those are genuinely different audiences. The existing vegan page carries a careful warning that serves_vegan means "has vegan options" and that calling mixed restaurants "vegan restaurants" would be false. The same discipline applies here: check whether the venue data has a vegetarian attribute distinct from vegan, and if it does not, say plainly on the page what the list is built from. Do not fake it.
6b. Food trucks, 170/mo, CPC $6.80. The highest advertising value of anything on the gap list by a wide margin, roughly ten times the CPC of the dining terms. Worth building for the ad revenue alone even at modest traffic. This one probably needs its own data pass, since food trucks are unlikely to be well represented in a Google Places pull for restaurants.
6c. Upscale and fine dining, roughly 590/mo across "nice restaurants in boulder", "upscale restaurants in boulder co" and neighbours. /cuisines/fine-dining-restaurant/ exists as a category page. This wants a ranked money page, and the price data you already hold is the perfect ranking signal: you can order by actual median entree price, which nobody else can do.
6d. Casual and cheap eats, roughly 660/mo across "best casual restaurants in boulder" and "cheap eats boulder". The homepage has a cheap eats section with no URL of its own. Give it one. Again the price data does the work: cheapest median entree, sorted.
Also worth doing, because Google is asking for them. The related searches on "best restaurants in boulder" include hidden gem restaurants Boulder, unique restaurants in Boulder, and best restaurants in Boulder with a view. The Michelin one already has a page. "With a view" is the standout for a town where the Flatirons are the view, and it likely maps onto data you already hold about patios and outdoor dining.
Test: each new page ranks in the site's own search, carries the full schema stack the /best/ template emits, and appears in the sitemap. Ranking in Google takes months and is not the test here.
Job 7. Fix the thin restaurant pages
🟡 High · One to two weeks · The one with real effort in it
548 restaurant pages at roughly 200 to 350 words each, flagged low_content_rate by the crawler. That is 75% of the site's URLs, published in a fortnight, on a domain with twelve referring domains.
Do not delete them. They are the dataset, they are the destination for long-tail brand searches ("bartaco boulder hours"), and a directory that omits restaurants is not a directory.
Do give them something no competitor's listing page has. In priority order:
- Inline priced menu items.
src/data/venue-menus.jsonalready covers 382 venues andsrc/pages/restaurants/[slug].astroalready imports it and renders a "Menu prices we found" section. Check how many venues actually render items rather than an empty section, and expand the number shown where the data supports it. - A price-position line. One generated sentence per venue, built from data you already have: "Mid-range for Boulder tacos. Cheapest item $4.00, most expensive $16.00, typical $11.00, against a Boulder taco median of $11.99." This is genuinely useful, genuinely unique, and it is a template change rather than 548 pieces of writing.
- Neighborhood and category context. "One of 32 Mexican restaurants in Boulder, and one of 14 in Downtown", each phrase linking to the relevant page. Improves the thin page and strengthens internal linking at the same time.
- Concentrate effort on the roughly 100 venues with real review volume. Leave the tail as it is. A restaurant with nine Google reviews does not need 800 words.
One caution on the review data. The Restaurant JSON-LD deliberately omits aggregateRating, and there is a comment in the template explaining why. Whoever made that call had a reason. Do not add it back as part of this work without finding out what the reason was.
Test: median word count across restaurant pages moves above 400, the crawler stops flagging low_content_rate on the top 100, and no page gains words without gaining information.
Job 8. Claim the Boulder Farmers Market cluster
🟡 High · Three to four days · The best unclaimed opportunity in the portfolio
8,100 searches a month on average, peaking at 14,800 in August, competition index 5. None of your seven Boulder properties has a page. It belongs on TasteBoulder because it is food, and because TasteBoulder already has the town-section machinery that the neighbouring markets need.
Honest read of what is winnable. The top three are bcfm.org (the official market), Visit Boulder, and Downtown Boulder. I would not promise displacing those. Positions four through eight are an Instagram profile, a Colorado Tourism page about Longmont ranking for a Boulder query, a Local Food Marketplace subdomain, and Visit Boulder's own insider guide. Those are takeable with a better page.
The cluster, in build order:
| Page | Query | Volume |
|---|---|---|
/farmers-market/ |
boulder farmers market | 8,100 |
/farmers-market/hours/ |
boulder farmers market hours | 260 |
/longmont/farmers-market/ |
longmont farmers market | 390 |
/farmers-market/parking/ |
(from related searches, no standalone volume) | tail |
What makes the page better rather than another listing. The related searches tell you what is unmet: schedule, today, parking, Saturday, Sunday. So: what is actually in season each month, which vendors sell what, where to park and what it costs, Wednesday versus Saturday and why they are different, and what a typical basket costs. That last one is your unfair advantage. You already price food in this town.
The seasonality is a gift, not a problem. Volume runs 1,000 in January and 14,800 in August. A page built in August 2026 has seven months to age and gather links before the 2027 season, which is exactly the runway a new domain needs.
Test: the page exists, covers all five related-search intents, and is in the sitemap. Then leave it alone and check position in ninety days.
Job 9. Links, which is the actual bottleneck
🔴 Critical · Ongoing · Nothing above works without this
The current profile: 14 backlinks, 12 referring domains, 11 of them nofollow, 5 unique IPs, spam score 40. Two of the twelve are your own pages.dev preview deploys. Three are .eu domains, one is a .wiki, one a .world. This is not a link profile, it is scraper noise.
Every job above is a multiplier on authority the site does not yet have. Jobs 1 through 8 make sure that when authority arrives it lands on pages aimed at real queries. Job 9 is the authority.
What I would do, in order of honesty about the odds:
- The Boulder Farmers Market page is itself a link play. A genuinely good seasonal guide is the kind of page local blogs and neighbourhood newsletters link to without being asked. Building it (Job 8) and pitching it are the same project.
- Local press and civic sites. Boulder Reporting Lab, Downtown Boulder, neighbourhood associations, the CVB's partner listings. These are slow, they are real, and they are the ones that count.
- Editorial cross-links between your seven Boulder properties, and this needs care. OutdoorBoulder's 118 trail pages are the only pages in the whole Boulder portfolio with any ranking traction. A hiker on
/mount-sanitas/is a genuine reader for "where to eat after a hike in Boulder". In-content, specific, one-directional links are fine. A reciprocal link block on all 1,200 pages is a footprint, and seven interlinked sites under one owner is exactly the pattern that gets recognized. Keep it editorial or do not do it. - The free-tool route. There is already a hub page arguing that your audit tool is worth more as a public free tool that earns links on its own than as a trade for links, and noting that trading an audit for a link is the named example in Google's own link spam policy. That argument still holds and it is the highest-ceiling option on this list.
What I would not do: anything that looks like a link exchange, directory submissions, or the 40 domains that link to all five of your competitors and not to you. I ran that query in a previous session and 39 of the 40 were spam directories and site-worth scrapers.
Test: referring domains from twelve to thirty, with the followed count going from one to at least ten. The follow ratio matters more than the total.
What not to do
- Do not chase "boulder weather" (135,000/mo, difficulty score 14). The score is wrong. The results are weather.com, AccuWeather and the National Weather Service. Every keyword tool will keep offering you this and it is not an opportunity.
- Do not change URL slugs yet. Titles and H1s are free. Slugs cost redirects, and redirects on a sixteen-day-old site with no authority risk losing the little indexing you have. Do slugs in a single planned pass later, using the
redirect-mapskill, once there is traffic worth protecting. - Do not submit to IndexNow, Google Search Console or Bing. The site is already indexed and discoverable. Whether to actively push it is your call, and my own plan is not your approval.
- Do not add
aggregateRatingto the Restaurant schema without first finding out why it was deliberately left out. - Do not worry about Core Web Vitals. 0 CLS, 275ms to interactive, 10KB transfer. There is nothing to fix.
- Do not retitle the cuisine pages that have a dish twin. That is the correction at the top of this page, and it will read as perfectly sensible right up until it costs you 1,980 searches a month.
Sequence and effort
| # | Job | Effort | Who | Blocks anything? |
|---|---|---|---|---|
| 1 | boulderthingstodo robots.txt | 2 min | Me | No |
| 2 | Homepage title to 60 chars | 2 min | Me | No |
| 3 | Verify 5 sites in Search Console | 20 min | You | Blocks all measurement |
| 4 | Retitle 41 dish pages | Half a day | Me | Do with 5 |
| 5 | Deconflict cuisine pages | Half a day | Me | Do with 4 |
| 6 | Four new dining pages | 2 to 3 days | Me | Needs 4 and 5 done first |
| 7 | Thicken restaurant pages | 1 to 2 weeks | Me | No |
| 8 | Farmers Market cluster | 3 to 4 days | Me | No, and it feeds 9 |
| 9 | Links | Ongoing | Both | Nothing works without it |
This week: 1, 2, 3, then 4 and 5 together in one session with one deploy. Next two weeks: 8, then 6. Background, continuous: 7 and 9.
Say the word and I will start on 1, 2, 4 and 5 now. Job 3 is the only one I cannot do for you.
How you will know any of this worked
Not by rankings, not for months. A sixteen-day-old domain with eleven nofollow links does not rank on a schedule, and anyone who tells you otherwise is guessing.
What to watch instead, in order of how soon it becomes readable:
- Week 1 to 2, once Search Console is on: how many of the 730 URLs Google indexes. This is the real health signal, and it either confirms or kills the thin-page worry in Job 7.
- Week 3 to 6: impressions on "best X in Boulder" phrases. Impressions arrive long before clicks. If the retitles worked, the queries in Search Console change shape even while positions are still deep.
- Month 2 to 4: average position on the dish and cuisine money terms starts climbing out of the 50s.
- Month 3 onward: the followed referring domain count. If that number is still one, nothing else on this list will matter.
Come back to this plan in ninety days with real Search Console data and we can throw out whichever half of it turned out to be wrong.
Published to Annette's hub. Rebuilt from the source markdown, so edit the source and rerun rather than editing this page.