
Project Story
Clinic hours, specialties and procedures as structured data, generated from a health system's CMS
Revere Health's website describes its clinics, providers, services and articles to search engines and AI assistants as schema.org JSON-LD, built from the same WordPress fields that render each page. We wrote a parser that turns the free-text hours editors type into opening hours, and it refuses to guess. 163 of 192 clinic pages now carry hours in their markup, up from none. The rest carry none at all.
- Client
- Revere Health
- Industry
- Healthcare
- When
- 2025 to 2026
- Status
- Live on reverehealth.com, with clinic hours in the markup since September 2026
- Next.js
- React
- TypeScript
- WordPress
- WPGraphQL
- Yoast SEO
- GraphQL Code Generator
- schema.org JSON-LD
- Algolia
- Vercel
What a clinic page tells a machine
Revere Health’s website has 2,861 pages. The content lives in WordPress, and a Next.js front end reads it over GraphQL and builds every page ahead of time. Most of those pages describe something a patient looks for by name. The sitemap lists 377 provider profiles, 591 service pages and 47 specialty pages. We saved 192 clinic pages (Revere calls them departments) from the live site to test against.
A person on a clinic page sees the address, the phone number, the hours, the specialties and the services offered there. A search engine or an AI assistant reading the same page has to work out which number is the phone and which line is the hours. Structured data takes that guess away. Each page carries a block of JSON-LD that says, in schema.org’s vocabulary, this is a MedicalOrganization, this is its Place, these are its openingHoursSpecification entries.
We started adding it in May 2025, one page type at a time. Providers became Physician nodes with their NPI number, specialties and credentials. The locations directory became a list of MedicalClinic entries with coordinates. Breadcrumbs, executive bios, forms, the newsroom and search followed. In September 2026 we went back for the gaps. Revere’s top search queries include local ones like “revere health provo” and “revere health lehi”, and a sample of 43 clinic pages showed hours on at least 35 of them. Not one clinic’s hours were in its markup.
One graph per page, tied together by @id
Each page’s structured data is built in getStaticProps from the same GraphQL result that renders the page, so the markup can’t drift from what a visitor reads. A clinic page becomes a handful of nodes:
- a
MedicalOrganizationfor the clinic, with its description and amedicalSpecialtyfor each specialty term assigned to it in WordPress - a
Placewith the address from the clinic’s map field, its phone and fax numbers and its opening hours - a
MedicalProcedurefor each service the clinic lists - a
BreadcrumbListand aWebSite
The nodes point at each other by @id instead of copying each other. The clinic’s parentOrganization points at the health system’s own #organization node, and so does the website’s publisher. Service pages make the same link from the other direction. A service page lists the clinics that offer it as MedicalClinic nodes carrying the same @id each clinic’s own page uses, each with an availableService pointing back at the procedure. A crawler that reads both pages can tell it’s looking at one clinic twice.
Two decisions went against what the original ticket suggested. schema.org has no provider property on MedicalProcedure, so the clinic says it offers the procedure instead. A clinic with no street address gets no MedicalClinic node on a service page, because Google treats MedicalClinic as a local business and requires an address.
Until September 2026 the clinic graph also nested one @graph inside another. It’s now one @context and one flat @graph array, checked on all 192 saved pages.
Reading hours the way editors type them
Revere’s editors type clinic hours into a single WordPress text field, in whatever shape reads well on the page. Usually that’s a line of days, a line break, then a line of times. Sometimes it’s two or three of those pairs, a split shift joined with “&”, “Every day” in place of day names, or a note like “*Closed for Holidays”. The old parseOpeningHours only understood a JSON format, so it skipped all of it. The live WordPress data held 108 distinct hours strings.
The parser reads what editors already write, so nothing changed for them. It normalizes the text first: <br /> tags become line breaks, entities are decoded and every kind of dash becomes a hyphen. Then it goes line by line. A line that starts with days (“Mon”, “Thurs”, “Monday through Friday”, “Mon/Tues/Thurs/Fri”) either carries its times or waits for the next line to supply them. Day names are matched longest first, so “Tuesday” is tried before “Tues” and “Tue”. Times become 24-hour HH:MM, and the result is one OpeningHoursSpecification per set of days and hours.
Wrong hours are worse than missing hours, so it fails closed. If any line that looks meaningful, meaning it has a day name or a digit, can’t be read completely, the whole field is rejected and the clinic goes out with no hours in its markup. It rejects:
- a time with no a.m. or p.m., since “8:30-6:30” would have to be guessed at
- anything that isn’t weekly, like “2nd Wednesday of each month” or “every other Wednesday”
- a day range that wraps around the weekend
- two entries that overlap on the same day
- a line of days that never gets its times
- a note that changes the schedule, like “Closed Fridays” or “Open weekends by appointment”
A note with no day name and no digit, like “Call for Hours”, is skipped. On its own, it leaves nothing to publish.
What it found on 192 clinic pages
Of the 108 distinct hours strings, 101 parse and 7 come back empty on purpose. Two say to call for hours and three describe a clinic open the second Wednesday of each month. One is every other Wednesday, and one gives times with no a.m. or p.m.
Run against the 192 saved clinic pages, the change took pages with openingHoursSpecification from 0 to 163. Of the other 29, 22 say “Call for Hours” and 5 are the monthly, every-other-week and missing a.m./p.m. cases. The last 2 have an empty hours field. We checked three clinics by hand against known output. One of them is open Monday to Thursday until 5:00 and Friday until 4:00, and it correctly comes out as two entries.
The same pull request fixed two quieter problems. The Place node used to be built only when a clinic was linked to a location term in WordPress, which left 36 clinics with an address on the page and none in the markup. It’s now built from the clinic’s own map field. And the specialty list had been read from a field that was never filled in. It now comes from the specialty terms the page itself displays, and on every saved page the names match exactly.
Descriptions go into the markup as plain text now, with tags stripped and entities decoded. Decoded text can contain a literal </script, which would end the JSON-LD block early, so the component that writes it escapes every < as \u003c. JSON parsers read that back as <.
Those checks ran once, against saved pages, when the change went in. The Revere repo’s CI runs lint, formatting and type checks. It has no test suite that reruns the parser on every build. If an editor invents a new way to write hours, that clinic will quietly have no hours in its markup until someone notices. That’s the failure we chose over publishing a wrong guess.
Who reads it, and what we can't promise
Google’s Rich Results Test will mostly show the breadcrumbs. MedicalOrganization, Place and MedicalProcedure aren’t types Google turns into rich results, and the pull request said so before it merged. What the markup does is hand anything that reads it the hours, address, phone and specialties as named fields. We don’t have a measurement of how often an AI assistant uses it, so we won’t claim one.
We were just as plain about llms.txt, a file that lists a site’s important pages for AI tools. Semrush’s AI search score checks for it, which is why we published one in August 2026. The pull request noted that none of the large AI companies had committed to reading it, and labeled the work a chore. The first version was 22 hand-picked links, each checked against the live site. Wherever our own count disagreed with what Revere publishes about itself, like 377 provider pages against “more than 400 providers”, it used Revere’s figure. In September it was expanded into a short index plus a full reference file linking every provider, specialty, location and department page.
Articles and service pages
The same day, the blog and the service pages got the same treatment. A review of 47 articles found three things to fix. Every author linked to an /author/ page the site doesn’t have, so articles written by Revere now name the organization as author. Articles by a person name them with no link. Articles with a medical reviewer set in WordPress already said “Medically reviewed by” on the page, and now carry it as reviewedBy. And dateModified had always equaled the publish date, because the query never asked WordPress for the modified field. It does now, and the date is clamped so it can never come before datePublished.
The 591 service pages had no structured data at all. Each one now has a MedicalProcedure with the Yoast meta description, or the body copy cut at a word boundary when there isn’t one. The breadcrumb runs Home, then the service, because there is no /services/ page and a breadcrumb shouldn’t point at a 404.
Keeping 2,861 static pages buildable
All of this lives inside a build that has to reach WordPress for every page. Pages are generated with fallback: false, so a URL that isn’t in WordPress at build time doesn’t exist. A short hiccup on the CMS used to be able to stop a deploy. fetchAPI now retries network errors, 429 and 5xx responses and responses that come back cut short. It waits 2, 4 and 8 seconds between tries. A 404 or a GraphQL error still fails at once. When a build does fail, the error is passed up instead of swallowed, so Vercel keeps serving the last good deploy. A page that fails to regenerate keeps its last good version too.
Editors preview drafts through a WordPress plugin that sends them to server-rendered preview routes for posts, pages, clinics, providers, locations, services and specialties. After each build, a postbuild step writes the sitemap and syncs adds, updates and deletes to the Algolia indexes behind the site’s search. The GraphQL queries are typed with GraphQL Code Generator, and the codebase has run on strict TypeScript since March 2026.
Related case study
Revere HealthQuestions about this project
HQ, a software design and development agency in Ogden, Utah, built the structured data on Revere Health's website, a headless WordPress site with a Next.js front end and 2,861 pages. Every clinic, provider, service, specialty and blog page carries schema.org JSON-LD generated from the same WordPress fields that render the page, including clinic opening hours parsed from the free text editors type. HQ has worked on the Revere Health site since 2023.
HQ wrote a parser for Revere Health that reads the hours editors type into WordPress, such as a line of days followed by a line of times, and converts them into OpeningHoursSpecification entries with 24-hour opening and closing times. It fails closed. If any line with a day name or a digit can't be fully understood, the clinic's markup is published without hours. That covers times without a.m. or p.m., monthly or every-other-week schedules, overlapping entries and wrap-around day ranges. Of 108 distinct hours strings on the live site, 101 parse and 7 are deliberately rejected. In testing, 163 of 192 clinic pages gained opening hours.
Clinic pages use a MedicalOrganization with medicalSpecialty for each specialty, a Place with the address, phone, fax and OpeningHoursSpecification, a MedicalProcedure for each service offered, a BreadcrumbList and a WebSite, all in one flat @graph. Provider pages use Physician with the NPI number, specialties and credentials. Service pages use MedicalProcedure plus a MedicalClinic node for each clinic that offers it, sharing the clinic page's @id and linking back through availableService. Blog posts use BlogPosting with reviewedBy for medically reviewed articles.
Mostly not for clinic details. Google's Rich Results Test generally shows only breadcrumbs for Revere Health's clinic pages, because MedicalOrganization, Place and MedicalProcedure aren't Google rich result types. HQ added the markup so search engines and AI assistants that read structured data get a clinic's hours, address, phone number and specialties as named fields. HQ hasn't measured how often AI assistants use it.
Yes. HQ published an llms.txt for Revere Health in August 2026, starting as 22 hand-checked links to the site's main sections and later expanded into a short index plus a full reference file that links every provider, specialty, location and department page. HQ shipped it because Semrush's AI search score checks for the file, and noted at the time that none of the large AI companies had committed to reading llms.txt.
Let's build together
Whether you're looking to design, build, update, or refresh an existing app or website, we can help. Even if we're not a good fit, we will help point you to somebody who might be.
Contact us