Bringing Cloudflare AI Search to Docus and VitePress
In July 2025, NuxtLabs joined Vercel. As a huge fan of the framework and the team, I was really happy to see them join a company like Vercel. Open-source work is extremely demanding, and the team was spending a lot of energy building a sustainable financial foundation. Joining Vercel gives them more room to focus on the framework and its ecosystem. They are doing a great job. Thank you, guys!
Bringing Cloudflare Into the Nuxt World
Since NuxtLabs joined Vercel, Vercel's services have naturally become more visible in the surrounding ecosystem. That preference does not extend to Nuxt itself, which remains platform-agnostic thanks in large part to Daniel's work on the framework. The same is true of the underlying UnJS primitives, thanks to Pooya's work.
I, however, am a Cloudflare user. I have been using many of its developer services for years, and sometimes the documentation only covers Vercel.
I want to change that. I want to bring Cloudflare further into the Nuxt ecosystem to make it more of a first-class citizen. Fortunately, the Nuxt team is open to contributions and really welcoming of changes that make their work compatible with more than Vercel. Honestly, it's totally understandable that they optimize for Vercel first. I'm just that guy who has decided to use Cloudflare.
Docus, the Nuxt Documentation Framework
The journey starts with Docus.
Docus is a documentation framework built on top of Nuxt Content. It allows you to create a documentation website in seconds with many features out of the box. It includes search, an assistant, an MCP server, agent skill distribution, and many other features that make it easy to create a documentation website. Oh, and it's beautiful.
For a long time, I used VitePress because it was much simpler. But since the rise of AI, Docus has become much better suited to needs such as assistants, agent access, and skill distribution. So, I'm now using Docus wherever I need a documentation website.
But if you take a look at the documentation, you'll see that the built-in assistant is Vercel-only and there's no integration with Cloudflare AI Search to replace the built-in full-text search.
The Nuxt Module for Docus
Docus is based on Nuxt. This means that we can create a Nuxt module to extend or override it.
In fact, Docus' search is powered by a component named AppSearch. We can change its behavior by simply swapping it with our own component. In a Nuxt module, it's as simple as registering a component with a higher priority than the built-in one so that our implementation takes precedence.
import { addComponent, createResolver, defineNuxtModule } from 'nuxt/kit'
export default defineNuxtModule<AiSearchOptions>({
setup() {
const resolver = createResolver(import.meta.url)
addComponent({
priority: 100,
name: 'AppSearch',
filePath: resolver.resolve('./runtime/components/AppSearch.vue'),
})
},
})Then we have full control over the AppSearch component. We can reuse the ContentSearch component from Nuxt UI and feed it results from the Cloudflare AI Search public endpoint.
That works like a charm!
I'm really happy with the result, and I'll definitely use it in my next projects. You can check the demo on docus-cloudflare-ai-search.barbapapazes.dev or the GitHub repository.
To use the module, you need to install it:
pnpm add docus-cloudflare-ai-searchThen, you need to add the module to your nuxt.config.ts:
import { defineNuxtConfig } from 'nuxt'
export default defineNuxtConfig({
modules: ['docus-cloudflare-ai-search'],
docus: {
aiSearch: {
endpoint: 'https://<your-domain>',
},
},
})While working on this module, I realized that the built-in assistant was only available through Vercel. Too bad, because having Cloudflare AI Search but no way to run the assistant through Cloudflare was far from what I'd call Cloudflare-native.
The assistant uses an AI gateway to route requests to its model. Docus supported Vercel AI Gateway, but it did not expose the configuration needed to use Cloudflare AI Gateway instead. So, I opened a PR to add the provider, gateway, and model configuration required for Cloudflare: feat(assistant): can use cloudflare for assistant. The PR keeps Vercel as the default and makes Cloudflare an optional provider configured through environment variables. As with everything in open source, you can just open a PR and make it happen.
Search Needs Something to Search
The integration works, which is a nice first step. But for now, our search index is empty. Cloudflare AI Search provides three ways to populate it:
- Using built-in storage, populated through the dashboard or Items API
- Connecting an R2 bucket
- Crawling a public website on a domain you own
Manual uploads through the dashboard do not fit because the content must be synchronized on every production deployment. An R2 bucket is viable, but uploading the content is not as simple as using rclone because each file also needs custom metadata. That would require additional tooling. Website crawling can extract custom metadata from HTML <meta> tags, but adding those tags to every generated page would also require a custom integration.
The best fit is therefore to populate the built-in storage through the Items API, which supports attaching custom metadata while uploading each file. This still needs a custom integration, but with the right one, we can really smooth out the developer experience.
For Nuxt, you can use the package cloudflare-ai-search-sync:
Install the package:
pnpm add cloudflare-ai-search-syncThen, add the module to your nuxt.config.ts:
export default defineNuxtConfig({
modules: [
'@nuxt/content',
'cloudflare-ai-search-sync/nuxt',
],
cloudflareAISearchSync: {
enabled: true,
},
})Now, each time you build your Nuxt project, Markdown files processed by Nuxt Content will be uploaded to Cloudflare AI Search with the right metadata. Feels like magic!
It's not mandatory to use this integration as long as you provide the correct metadata for your content.
Going Above and Beyond
Bringing a new feature to the Nuxt ecosystem was really easy thanks to the Nuxt module system. It provides extension points for a wide range of use cases. Just take a look at the Nuxt modules page to see the possibilities.
To try an idea locally, modules are the perfect fit. Create a Nuxt project, add a modules folder, and start hacking on your idea. If you want to publish the module, the official Nuxt module starter provides the full project structure. Give your AI assistant the Nuxt modules documentation, and you can get a prototype running surprisingly quickly. There's never been a better time to try it.
But not everything in the Vue ecosystem is Nuxt. We have VitePress and tons of Vite plugins to create our own systems. Speaking of Vite plugins, they are a good way to reach a wider audience. A Vite plugin can often serve frameworks such as Astro, Svelte, React, and even Nuxt, although each integration may still require framework-specific work. However, Vite plugins are much harder to create. So, starting at a high level with a Nuxt module, testing whether the idea really works, and then moving down the stack to create a Vite plugin is a good way to proceed.
And that's exactly what I did with the Cloudflare AI Search integration.
VitePress Was the First Obvious Choice
Once the proof of concept was validated for Docus, I started thinking about how to make it available for VitePress. I started as a VitePress user, my portfolio still uses it, and the framework has a large documentation community.
Also, VitePress already has first-party support for local and Algolia search, as well as multiple third-party plugins for other search engines. So, bringing Cloudflare AI Search to VitePress was a natural choice, and I could leverage the work already done for the Docus integration.
Using the Cloudflare AI Search Snippet, I was able to quickly integrate the search engine into VitePress. With the POC validated, I created a dedicated VitePress plugin: vitepress-plugin-cloudflare-ai-search.
Under the hood, the plugin is pretty simple. It swaps the built-in VPNavBarSearch component with a custom one that integrates the Cloudflare AI Search snippet and receives its configuration through a virtual module.
To get started, you need to install the plugin:
pnpm add vitepress-plugin-cloudflare-ai-searchThen, you need to add the plugin to your config.ts:
// .vitepress/config.ts
import { defineConfig } from 'vitepress'
import { cloudflareAISearch } from 'vitepress-plugin-cloudflare-ai-search'
export default defineConfig({
vite: {
plugins: [
cloudflareAISearch({
endpoint: 'https://<your-domain>',
}),
],
},
})You may also want to upload your content to the AI Search engine to make it searchable. To do that, you can also use the package cloudflare-ai-search-sync to handle this automatically.
Install the package:
pnpm add cloudflare-ai-search-syncThen, add the plugin to your config.ts:
import { cloudflareAISearchSync } from 'cloudflare-ai-search-sync/vitepress'
import { defineConfig } from 'vitepress'
export default defineConfig({
buildEnd: cloudflareAISearchSync({ enabled: true }),
})Now, you can build your VitePress project and the content will be synced to AI Search. Feels like magic!
If you prefer to simply give it a try, you can check the demo on vitepress-plugin-cloudflare-ai-search.barbapapazes.dev or the GitHub repository.
Not Everything Worked as Expected
You may think: "Oh great, that works well!" But that was not what happened initially, and I learned it the hard way. At the time, Cloudflare's documentation explained how to embed the UI snippet, but it did not clearly connect that process to indexing content with the metadata expected by the component. Without indexed content in the expected shape, the UI snippet does not work.
As mentioned earlier, there are three ways to make content available to the search engine:
- Using built-in storage through the dashboard or Items API
- Connecting an R2 bucket
- Crawling a public website
None of them worked out of the box with the snippet. Even worse, after tweaking them and trying to change the item keys or metadata, nothing worked. So, I did what I do best: I went to the source code for the EmDash integration and UI snippet. Luckily for me, it's open source.
I discovered that EmDash does not query the public endpoint directly. Instead, its integration exposes a dedicated endpoint that rewrites the metadata in the AI Search response to match the shape expected by the snippet. Sad news. I also inspected the network responses from Cloudflare's blog search to understand the expected shape, and that confirmed my discovery.
I'm not going to let this get me down. First, let's find a way to make it work. Then, let's open some PRs to improve the situation. And finally, let's ship a demo to showcase how cool the product is when it works.
I dug into the source code of the snippet, especially the modal, and discovered that it used the item key as the URL when a user clicked a search result. The problem is that an AI Search key is not necessarily a public URL. It's a path to the item in storage. So, if you upload a file named my-file.md in the docs directory, the key will be docs/my-file.md. But the snippet uses the key as a link, so clicking the result opens https://<your-domain>/docs/my-file.md, which is not the right URL. I wanted it to open https://<your-domain>/docs/my-file.
But why not just push /docs/my-file as the key? Because it is not a valid AI Search item key. Item keys cannot start with / and must include a file extension. Also, the snippet can differentiate pages and sections to improve the search experience. That's a good idea until you realize that linking to a section requires a # fragment in the public URL, which cannot be represented in the item key.
I can't rewrite the key on the fly because the promise of the snippet is to use it with the public endpoint and make it work out of the box, without configuration or a server.
So, instead of using the key, I decided to use a custom metadata field to store the URL. AI Search allows us to define additional metadata fields and attach their values to uploaded items. However, this also requires a PR to the snippet to make it work:
So, I opened the PR. In the meantime, I also opened another one to update the outdated README:
In the VitePress and Nuxt adapters, I derive each page's public URL from its source path and store that URL in the item's custom metadata.
I also had to create the adapters that synchronize content to AI Search. We needed custom metadata and wanted the simplest developer experience, where you install the integration and you're good to go. R2 required additional upload tooling, and crawling meant adding metadata to every generated website page. The best option was to use the Items API to upload Markdown files and their metadata directly to built-in storage.
Finally, something was working! Huge!
But that only worked in my local workspace. Sad!
While PR #44 remains open, the upstream snippet still uses the item key as the URL. My local workspace used the patched dependency, but the published plugin dynamically loaded the upstream package instead. To make it work today, I had to bundle the patched snippet into the plugin. It was a little hacky because the module is loaded dynamically in a Vue component outside the usual bundling pipeline, so adding it to alwaysBundle in the tsdown configuration was not enough. I had to be more creative.
In the end, it works. See the VitePress demo on vitepress-plugin-cloudflare-ai-search.barbapapazes.dev and the Nuxt module demo on docus-cloudflare-ai-search.barbapapazes.dev.
Cloudflare's developer experience is getting better and better, but newer products can still require digging into source code and trying the system multiple times when the documentation and integrations evolve at different speeds.
In the end, it works, and I'm sure they will quickly address the issues! Their PMs are really open to feedback from the community.
What to Keep in Mind
First, open source is amazing. Build open-source projects and contribute to open source. It's one of the best ways to learn and grow as a developer. But don't forget that it's just a side effect of doing things.
Second, start narrow and go wider. I began with a Nuxt module because it was the easiest way to validate the idea. After sharing a short video on X and collecting feedback, I extracted the synchronization process into a dedicated package and expanded the integration to VitePress. Interestingly, the VitePress version was ready first, and the Nuxt module followed later.
Third, be persistent and patient. Sometimes, things don't work as expected and are harder than they seem. However, that does not mean that it's impossible. By persisting and pushing a little bit more, you can make it work, and the reward is always worth it.
Thanks for reading! My name is Estéban, and I love to write about web development and the human journey around it.
I've been coding for several years now, and I'm still learning new things every day. I enjoy sharing my knowledge with others, as I would have appreciated having access to such clear and complete resources when I first started learning programming.
If you have any questions or want to chat, feel free to comment below or reach out to me on Bluesky, X, and LinkedIn.
I hope you enjoyed this article and learned something new. Please consider sharing it with your friends or on social media, and feel free to leave a comment or a reaction below, it would mean a lot to me! If you'd like to support my work, you can sponsor me on GitHub!
Discussions
Add a Comment
You need to be logged in to access this feature.