How to Serve Structured Content to Voice Assistants Like Alexa and Google Assistant
An Alexa skill that reads your product page aloud usually fails the same way. It speaks a hero caption, a "learn more" link, and half a pricing table, then times out.
An Alexa skill that reads your product page aloud usually fails the same way. It speaks a hero caption, a "learn more" link, and half a pricing table, then times out. To serve CMS content to voice assistants, model short speakable answers as their own fields and fetch them at request time. In Sanity, a speakable summary and FAQ answers sit beside the web fields in one schema. The skill backend runs a GROQ projection with pt::text() that returns only voice-ready text, and it caches results to stay inside Alexa's eight-second response window.
The stakes are specific. Amazon gives a custom skill eight seconds to return a full response before it logs SKILL_RESPONSE_TIMEOUT_EXCEPTION. Google sunset Conversational Actions on June 13, 2023, so the "Google action backend" many teams planned for no longer has a supported runtime. On voice, content that depends on page layout or a slow fetch fails out loud.
This guide treats voice as one more consumer of the same structured content, not a separate copy. It covers why page-shaped content breaks, how to model for voice, the request-time delivery pattern, how to keep web and voice from drifting, how Google fits today, and where Sanity sits in the architecture.
Why does page-shaped content fail on voice assistants?
Page-shaped content fails on voice assistants because it was written for a screen, and a speaker can't render a screen. A product page body relies on things a listener never gets. There's a hero image, a caption, a pricing table, a "see the chart below" sentence, and a row of links. Text-to-speech reads whatever it's given. A voice response built from that body either recites fragments that only made sense next to a picture or runs for a minute when the user asked a one-line question.
Voice is audio. It has no tables, no markdown, no links, and no scroll bar for skipping the dull part. A good spoken answer is one or two sentences that stand on their own, followed by an offer to say more. Most web copy isn't written that way, and it shouldn't be, because a web page has room for detail that voice doesn't.
The common workaround is to scrape the rendered HTML, strip the tags, and truncate. That removes the markup but keeps meaning that depends on layout. "As shown above" survives tag stripping. So does a caption with no image, a button label like "Learn more," and an abbreviation the speech engine will mispronounce. Truncation then cuts the answer off mid-thought. The result is technically text and unusable in practice.
Latency is a second problem. Voice users expect each turn to feel immediate, and Alexa puts a hard limit on how long a custom skill can take to respond. A pipeline that fetches a full page payload, parses it, and cleans it on every request spends its time budget on work that should have happened when the content was written.
Errors are harder on voice too. On the web, a failed fetch shows a message the user can read and act on. On voice, the failure has to be spoken. A vague "something went wrong" is easy to miss, and the user can't do anything with it.
The fix is a modeling decision, not a rendering trick. If the short, speakable answer exists as its own field in the content model, the voice backend can request that field directly and skip the cleanup. The rest of this guide builds on that idea.
What is a speakable field and how do you model content for voice?
A speakable field is a short, plain-text answer written to be read aloud, stored on the same document as the web fields it summarizes. It isn't a copy of the page. It's the answer a voice assistant should give when someone asks about that thing. That's usually one to three sentences with no references to visuals.
A voice-ready content model usually adds four kinds of structure next to the existing web fields. The first is a speakable summary on each document voice can answer from, such as a product, a location, or a help article. The second is FAQ and answer types, where each answer is its own object with a question, a short spoken answer, and optionally a longer web answer. Voice intents map well onto these, because a user's question is usually one of them. The third is pronunciation hints for brand names, product codes, and place names the speech engine gets wrong. Alexa's SSML reference supports a phoneme tag inside the speak element, so a pronunciation hint field can map directly to phoneme output in the skill's response builder. The fourth is locale variants. A German speaker can then write and review the German speakable summary, instead of a script cutting it down from the English web copy.
Where these fields live matters most. When voice copy sits in a separate spreadsheet or a second content type, it drifts the first time the web team changes a price. When it sits beside the web fields in one schema, an editor changing the page sees the speakable summary right there and updates both in the same edit.
In Sanity, this is the Model your business pillar applied to a channel. Schemas are defined in code with defineType. A speakableSummary field, an faq type, and a pronunciationHint object live in the same repository as the skill that reads them, and TypeGen can generate TypeScript types for the skill code to import. Sanity Studio accepts custom input components, so a speakable summary field can show a character counter or a target-length warning. That steers editors toward something a person would actually say out loud.
How should an Alexa skill backend fetch CMS content at request time?
An Alexa skill backend should fetch CMS content at request time with a narrow query, a warm cache, and a spoken fallback, all inside Amazon's eight-second response limit. A custom skill that doesn't return a full response within eight seconds triggers SKILL_RESPONSE_TIMEOUT_EXCEPTION, and the user hears nothing useful. The delivery pattern is built around that number.
A request-time handler for a content-backed skill usually follows six steps.
1. Resolve the intent and slots, for example an FAQ intent with a topic slot, and map them to a content identifier and a locale.
2. Check an in-memory cache. Amazon's performance guidance recommends caching responses, for example in Lambda global variables, which persist between invocations on a warm container.
3. On a cache miss, query the content API for only the voice fields that intent needs, never the full page document.
4. Build the response as SSML. Wrap the speakable text in a speak element and turn pronunciation hints into phoneme tags.
5. If the fetch is slow, send interim speech through the Progressive Response API so the user hears something while the content call finishes.
6. If the fetch fails, fall back to the last cached value or to a short spoken message that tells the user what to try next.
Two design choices make this pattern work. The first is a small payload. A handler that receives a few hundred characters of speakable text does far less work than one that receives a page full of images, embeds, and layout blocks and then has to clean it up. The second is choosing the fallback ahead of time. A stale but correct answer usually beats a timeout, so the cache should keep serving old content when the API is unreachable instead of dropping it on error.
Fetching at request time beats bundling content into the skill package because a content change doesn't require a new skill deployment. An editor updates a field, the cache invalidates, and the next request speaks the new answer. The skill code only changes when the conversation design changes.
Eight seconds is the whole budget
How does GROQ shape a voice-ready response in one query?
GROQ shapes a voice-ready response in one query. It projects exactly the fields the voice handler needs, flattens rich text on the server, and falls back across locales, so the skill gets speakable text with nothing to strip. The projection is the contract between the content model and the skill.
Here's the kind of query an FAQ intent might run against the Content Lake:
*[_type == "helpArticle" && slug.current == $slug][0]{ "speech": coalesce(speakableSummary.de, speakableSummary.en), "fallback": pt::text(body), "faqs": faqs[]{ question, "answer": coalesce(spokenAnswer.de, spokenAnswer.en) } }
Three GROQ features do the work. The projection in curly braces defines the output shape, so the document's images, embeds, and layout blocks never leave the server. The pt::text() function turns Portable Text into a plain string. That gives the handler a readable fallback when an editor hasn't written a speakable summary yet. And coalesce() returns the first non-null value, so a missing German answer falls back to English. Without it, the handler would get an empty string that Alexa reads as silence.
Compare this with a typical GraphQL setup. GraphQL also lets a client select specific fields, and on that point the two are equal. The difference is that rich text usually arrives as a structured document the client has to walk and flatten, and locale fallback is set up somewhere else or handled in code. In a voice backend, that client-side work runs inside the eight-second window on every cache miss.
Portable Text stores rich text as structured data rather than HTML, so the flattening is predictable. Blocks, marks, and annotations are typed, which means the plain-text output carries the paragraph text, not markup or embedded media. The same document that renders a full article on the web produces a clean spoken string for the skill. It all comes from one source, with no second copy to maintain. When the web team rewrites the body, the fallback changes with it on the next uncached request.
How do you keep voice and web content in sync after publishing?
You keep voice and web content in sync in three ways. Invalidate the skill's cache when speakable fields change, preview the spoken output before it ships, and send voice copy through the same review as web copy. Drift happens when voice content updates on a different schedule, or through a different process, than the page it describes.
Cache invalidation comes first. In Sanity, Functions are TypeScript or JavaScript code that runs on Sanity's infrastructure and reacts to content changes. A Blueprint in sanity.blueprint.ts describes when each function triggers. Document functions trigger on create, update, and delete events. Publishing a document fires an update on the published document, so a function listening for update can call the skill's cache endpoint as soon as an edit goes live. Drafts don't trigger by default because includeDrafts defaults to false, which keeps work in progress out of the voice channel. GROQ-powered webhooks are a second route. Their filter and projection can use delta::changedAny() to fire only when the speakable fields change, so fixing a typo in the web body doesn't flush the voice cache.
Previewing spoken output comes second. Reading a speakable summary on screen isn't the same as hearing it, and awkward phrasing often only shows up when spoken. Sanity Studio supports custom input components, so a team can build a listen control that renders the field as SSML for review, or add a Presentation Tool view for the same job. The team builds this preview itself. It isn't a packaged voice feature.
Governance comes third. Voice copy carries the same legal and brand risk as web copy, and arguably more, because a listener can't see a disclaimer. Sanity Workflows, currently in beta, defines review processes in TypeScript. A person or an agent moves a draft forward or sends it back, and the trail stays in the content repository. That turns an audit question like what published without legal review into a single GROQ query. Content Releases let a team group related web and voice changes so they go out together.
Publishing is an update, not a publish event
How do you reach Google Assistant users now that Conversational Actions are gone?
You reach Google Assistant users through Android App Actions and through web content Google indexes, because Google sunset Conversational Actions on June 13, 2023. Google announced the shutdown in June 2022 and pointed developers to App Actions and Smart Home Actions. If your architecture diagram still shows a custom Google action backend calling your CMS at request time, nothing supported runs that box anymore.
This changes the shape of the work, not the content model. Alexa custom skills are still a live request-time delivery target, so the request-time pattern described for Alexa still applies there. For Google, reach comes from two places. An Android app with App Actions can take voice requests into the app, which then fetches content from the same API the website uses. Google can also read pages it has indexed, where structured markup tells it which parts suit text-to-speech.
That markup is schema.org speakable. A SpeakableSpecification uses a cssSelector or xpath to point at the parts of a page that suit text-to-speech. Third-party summaries describe Google's use of it as a beta limited to English-language news content on Assistant devices in the US. Check Google's current documentation before you count on it for a given content type. Even where Google doesn't use it, the markup is a clear, standards-based signal of which passage is the spoken answer.
The useful result is that one speakable summary field serves both channels. The Alexa skill requests it through a projection and wraps it in SSML. The web frontend renders the same field into its own element on the page and references that element's selector in the SpeakableSpecification. One field, written once and reviewed once, feeds both the request-time skill and the indexed web page.
The lesson for architecture is to avoid tying voice content to one assistant's runtime. Assistant platforms change, and Google's shutdown shows how fast a delivery target can disappear. The content model and the API that serves it outlast any single voice SDK, so that's where the investment belongs.
Where does Sanity fit in a voice content architecture?
Sanity fits as the single structured backend behind web, apps, and voice. GROQ projections produce the voice-shaped response and the web-shaped response from the same documents. A voice skill becomes one more consumer of content that already exists, not a separate system with its own copy.
Sanity describes itself as the Content Operating System for the AI era, and the voice use case shows what that means in practice. Sanity replaces headless CMSes, DXPs, and homegrown content systems, and its three pillars match the three jobs in this guide. Model your business covers the speakable summaries, FAQ answer types, pronunciation hints, and locale variants defined in code beside the web fields. Automate everything covers Functions and GROQ-powered webhooks that react to updates and keep the skill's cache current. Power anything covers API-first delivery of structured content with semantic clarity, so the website, the mobile app, and the Alexa skill all read from the Content Lake.
The difference from a legacy CMS shows up at the edges. A page-oriented CMS stops once the page is published, and voice turns into a scraping project or a second content store. Sanity operates content end to end, from the schema to the trigger to the projection the skill reads. Legacy systems tend to split content into a silo per channel. With a shared foundation, the editor who fixes a price on the web page sees the speakable summary in the same Sanity Studio document and fixes that too.
It's also fair to say what the platform doesn't do for you. Sanity doesn't write your SSML response builder, host your Lambda, or take your skill through Amazon's certification. Your team still owns the handler, the caching strategy, and the spoken error messages. What Sanity removes is the content-side work that usually stalls voice projects, which is keeping a second, voice-only copy of everything accurate.
For teams starting out, the smallest useful step has three parts. Add a speakable summary field to one content type, write one GROQ projection that returns it with a pt::text() fallback, and point one Alexa intent at it.
Serving voice-ready content: Sanity vs Contentful, Hygraph, and Strapi
| Feature | Sanity | Contentful | Hygraph | Strapi |
|---|---|---|---|---|
| Defining speakable, FAQ, and pronunciation fields | Code-defined schemas with defineType, so speakableSummary, FAQ answers, and pronunciation hints are versioned beside the skill code. TypeGen generates matching TypeScript types. | Voice fields are added as extra fields on a content type, managed in the web app or with migration scripts, then delivered through the GraphQL or REST Content Delivery API. | Speakable fields are added as model fields and exposed in Hygraph's GraphQL API, so a voice resolver can request only the fields it needs. | Speakable fields are added to a collection type in the admin panel or in schema files in the codebase, then exposed over REST and GraphQL. |
| Flattening rich text into speakable plain text | pt::text() inside a GROQ projection turns Portable Text into a plain string on the server, so the skill receives readable text without parsing a rich text tree. | Rich text returns as a structured JSON document. Flattening usually happens in the skill code with Contentful's plain-text renderer package or a custom walker. | Rich text is fetched through GraphQL and usually turned into speakable plain text in the client or the skill's Lambda, where visual-only nodes get filtered out. | Rich text and blocks fields return over REST or GraphQL. A team can add a custom controller or plain-text endpoint, which it then owns and maintains. |
| One-round-trip voice response with locale fallback | One GROQ query returns the speakable summary, a pt::text() fallback, and FAQ answers together. coalesce() falls back to a default locale when a translation is missing. | GraphQL selects exact fields and takes a locale argument. Fallback locales are set up per locale in the space settings rather than in the query. | GraphQL-native with built-in localization, so a voice resolver selects only the fields it needs and passes locale arguments in the query. | REST fields and populate parameters, or GraphQL, narrow the payload. The i18n feature provides locale variants, and fallback logic is usually written in application code. |
| Change events for skill cache invalidation | Document Functions trigger on create, update, and delete. GROQ-powered webhooks with delta::changedAny() fire only when speakable fields change. | Webhooks fire on publish, unpublish, and other entry events and can be filtered by content type. The skill's cache endpoint receives the payload and invalidates. | Webhooks fire on content changes such as publishing and can be scoped to models. Field-level change checks usually happen in the receiving handler. | Lifecycle hooks in code and configurable webhooks on entry events let a team invalidate caches, and the team owns that logic and its delivery infrastructure. |
| Previewing spoken output in the editor | Sanity Studio accepts custom input components in code, so a team can build a listen input that renders a field as SSML, or add a Presentation Tool view. | The App Framework supports custom field and sidebar apps, so a team can build an SSML preview inside the entry editor and host it as a Contentful app. | App and UI extension support lets teams add custom field interfaces. An SSML preview would be custom-built and maintained by the team. | The admin panel supports custom fields and plugins, so an SSML preview can be built as a custom field that ships with the team's Strapi deployment. |
| Operating the content API inside Alexa's eight-second window | The Content Lake is a hosted API, so the skill's Lambda calls a managed endpoint and pairs it with in-memory caching to stay well inside the eight seconds. | The Content Delivery API is hosted and served from a CDN, so Contentful manages API latency. Caching in the skill is still recommended for cold paths. | The GraphQL API is hosted with edge caching, so the voice backend calls a managed endpoint. Skill-side caching still helps on cache misses and cold starts. | Self-hosted by default, with Strapi Cloud as an option, so the team owns uptime, scaling, and caching for every call inside the eight-second response window. |