After wrapping up my last project—a chat interface to search GitHub—I found myself searching for the next idea to tackle. As a developer, inspiration often comes unexpectedly, and this time, it struck while scrolling through my GitHub feed. A repo, starred by Daniel Roe (Nuxt Core Team Lead), caught my eye. It was an Electron-based voice notes app designed for macOS.
Something about the simplicity of voice notes combined with the technical challenge intrigued me. Could I take this concept further? Could I build a modern, AI-powered voice notes app using web technologies? That urge to build led me here, to this blog post, where I’ll walk you through building Vhisper, a voice notes app with AI transcription and post-processing, built with the Nuxt ecosystem and powered by Cloudflare.
And before you say it, I must make a confession: “Hi! My name is Rajeev, and I am addicted to talking/chatting.”.
Project Overview
Now that the formalities are done, let’s focus on what we’ll be building in this project. The goal is to create Vhisper, a web-based voice notes application with the following core features:
Recording Voice Notes: Users can record voice notes directly in the browser.
AI-Powered Transcription: Each recording is processed via Cloudflare Workers AI, converting speech to text.
Post-Processing with Custom Prompts: Users can customize how transcriptions are refined using an AI-driven post-processing step.
Seamless Data Management (CRUD): Notes and audio files are efficiently stored using Cloudflare’s D1 database and R2 storage.
To give you a better sense of what we’re aiming for, here’s a quick demo showcasing Vhisper’s main features:
By the end of this guide, you’ll know exactly how to build and deploy this voice notes app using Nuxt, NuxtHub and Cloudflare services—a stack that combines innovation with developer-first simplicity. Ready to build it? Let’s get started!
Project Setup
Before setting up the project let’s review the technologies used to build this app:
Nuxt: Vue.js framework for the application foundation
Nuxt UI (v3): For creating a polished and professional frontend
exportdefaultdefineNuxtConfig({modules:["@nuxthub/core","@nuxt/eslint","nuxt-auth-utils","@nuxt/ui"],devtools:{enabled:true},runtimeConfig:{public:{helloText:"Hello from the Edge đź‘‹",},},future:{compatibilityVersion:4},compatibilityDate:"2024-07-30",hub:{ai:true,blob:true,database:true,},css:["~/assets/css/main.css"],eslint:{config:{stylistic:false,},},});
We’ve made the following changes to the Nuxt config file:
Updated the Nuxt modules used in the app
Enabled required NuxtHub features
And, added the main.css file path.
Create the main.css file in the app/assets/css folder with this content:
@import"tailwindcss";@import"@nuxt/ui";
Testing the Setup
Run the development server:
pnpm dev
Visit http://localhost:3000 in your browser. If everything is set up correctly, you’ll see the message: “Hello from the Edge 👋” with a refresh button.
đź’ˇ Troubleshooting Tip: If you encounter issues with TailwindCSS, try deleting node_modules and pnpm-lock.yaml, and then run pnpm install to re-install the dependecies.
Building the Basic Backend
With the project setup complete, let’s dive into building the backend. We’ll begin by creating API endpoints to handle core functionalities, followed by configuring the database and integrating validation.
But before jumping to code, let’s understand how you’ll interact with various Cloudflare offerings. If you’ve been attentive, you should know the answer, NuxrHub, but what is NuxtHub?
What is NuxtHub?
NuxtHub is a developer-friendly interface built on top of Cloudflare’s robust services. It simplifies the process of creating, binding, and managing services for your project, offering a seamless development experience (DX).
You started with a NuxtHub template, so the project comes preconfigured with the @nuxthub/core module. During the setup, you also enabled the required Cloudflare services: AI, Database, and Blob. The NuxtHub core module exposes these services through interfaces prefixed with hub. For example, hubAI is used for AI features, hubBlob for object storage, and so on.
Time is ripe now to work on the first API endpoint.
/api/transcribe Endpoint
Create a new file named transcribe.post.ts inside the server/api directory, and add the following code to it:
// server/api/transcribe.post.ts exportdefaultdefineEventHandler(async (event)=>{constform=awaitreadFormData(event);constblob=form.get("audio")asBlob;if (!blob){throwcreateError({statusCode:400,message:"Missing audio blob to transcribe",});}ensureBlob(blob,{maxSize:"8MB",types:["audio"]});try{constresponse=awaithubAI().run("@cf/openai/whisper",{audio:[...newUint8Array(awaitblob.arrayBuffer())],});returnresponse.text;}catch (err){console.error("Error transcribing audio:",err);throwcreateError({statusCode:500,message:"Failed to transcribe audio. Please try again.",});}});
The above code does the following:
Parses incoming form data to extract the audio as a Blob
Verifies that it’s an audio blob and is less than 8MB in size using a @nuxthub/core utility function ensureBlob
Passes on the array buffer to the Whisper model through hubAI for transcription
Returns the transcribed text to the client
Before you can use Workers AI in development, you’ll need to link it to your Cloudflare project. As we’re using NuxtHub as the interface, running the following command will create/link a new or existing NuxtHub project with this project.
npx nuxthub link
/api/upload Endpoint
Next, create an endpoint to upload the audio recordings to the R2 storage. Create a new file upload.put.ts in your /server/api folder and add the following code to it:
The above code uses another utility method from the NuxtHub core module to upload the incoming audio files to R2. handleUpload does the following:
Looks for the files key in the incoming form data to extract blob data
Supports multiple files per event
Ensures that the files are audio and under 8MB in size
And, finally uploads them to your R2 bucket inside recordings folder while also adding a random suffix to the final names
Returns a promise to the client that resolves once all the files are uploaded
Now we just need /notes endpoints to create & fetch notes entries before the basic backend is done. But to do that we need to create the needed tables. Let’s tackle this in next section.
Defining the notes Table Schema
As we will use drizzle to manage and interact with the database, we need to configure it first. Create a new file drizzle.config.ts in the project root, and add the following to it:
The config above mentions where the database schema is located, and where should the database migrations be generated. The database dialect is set to sqlite as that is what Cloudflare’s D1 database supports.
Next, create a new file schema.ts in the server/database folder, and add the following to it:
The above code reads the validated event body, and creates a new note entry in the database using the drizzle composable we created earlier. We will get to the validation part in a bit.
index.get.ts
// server/api/notes/index.get.tsexportdefaultdefineEventHandler(async (event)=>{try{constnotes=awaituseDrizzle().select().from(tables.notes).orderBy(desc(tables.notes.updatedAt));returnnotes;}catch (err){console.error("Error retrieving note:",err);throwcreateError({statusCode:500,message:"Failed to get notes. Please try again.",});}});
Here we fetch the notes entries from the table in descending order of updatedAt field.
Incoming data validation
As mentioned in the beginning, we’ll use Zod for data validation. Here is the relevant code from index.post.ts that validates the incoming client data.
Create a new file note.schema.ts in the shared/schemas folder in the project root directory with the following content:
// shared/schemas/note.schema.tsimport{createInsertSchema,createSelectSchema}from"drizzle-zod";import{z}from"zod";import{notes}from"~~/server/database/schema";exportconstnoteSchema=createInsertSchema(notes,{text:(schema)=>schema.text.min(3,"Note must be at least 3 characters long").max(5000,"Note cannot exceed 5000 characters"),audioUrls:z.string().array().optional(),}).pick({text:true,audioUrls:true,});exportconstnoteSelectSchema=createSelectSchema(notes,{audioUrls:z.string().array().optional(),});
The above code uses the drizzle-zod plugin to create the zod schema needed for validation (The above validation error messages are more suitable for the client side. Feel free to adapt these validation rules to suit your specific project requirements.).
Creating DB Migrations
With the table schema and API endpoints defined, the final step is to create and apply database migrations to bring everything together. Add the following command to your package.json's scripts:
Next, run pnpm run db:generate to create the database migrations. These migrations are auto applied by NuxtHub when you run or deploy your project. You can test it by running pnpm dev and checking the Nuxt Dev Tools as shown below (this is a local sqlite database that is used in the dev mode).
We are done with the basic backend of the project. In the next section, we will code the frontend components and pages to complete the whole thing,
Creating the Basic Frontend
We’ll start with the most important feature first: recording the user voice, and then we’ll move on to creating the needed components and pages.
useMediaRecorder Composable
Let’s create a composable to handle the media recording functionality. Create a new file useMediaRecorder.ts in your app/composables folder and add the following code to it:
Exposes recording start/stop functionality along with the current recording readonly state
Captures user’s voice using the MediaRecorder API when startRecording function is invoked. The MediaRecorder API is a simple and efficient way to handle media capture in modern browsers, making it ideal for our use case.
Captures audio visualization data using AudioContext and AnalyserNode and updates it in real-time using animation frames
Cleans up resources and returns the captured audio as a Blob when stopRecording is called or if the component unmounts
NoteEditorModal Component
Next, create a new file NoteEditorModal.vue in the app/components folder and add the following code to it:
Displays a textarea for allowing a manual note entry
The modal integrates the NoteRecorder component for voice recordings and manages the data flow between the recordings and the textarea for user notes.
Whenever a new recording is created, it captures the emitted event from the note recorder component, and appends the transcription text to the textarea content
When the user clicks the save note button, its first uploads all recordings (if any) by calling the note recorder’s uploadRecordings method, and then save the note by calling the notes API endpoint created earlier.
The save note button first uploads all recordings (if any) asynchronously by calling the uploadRecordings method, then sends the note data to the /api/notes endpoint. Upon success, it notifies the parent by executing the callback passed by it, and then closes the modal.
NoteRecorder Component
Create a new file NoteRecorder.vue in the app/components folder and add the following content to it:
Allows recording the user’s voice with the help of useMediaRecorder composable created earlier. It also integrates the AudioVisualizer component to enhance the user experience by providing real-time audio feedback during recordings.
On a new recording, sends the recorded blob for transcription to the transcribe API endpoint, and emits the transcription text on success
Displays all recordings as audio elements for users perusal (using URL.createObjectURL(blob)). It utilizes the useRecordings composable to manage the recordings
Uploads the final recordings to R2 (the local disk in dev mode) using the /api/upload endpoint, and returns the pathnames of these recordings to the caller (the NoteEditorModal component)
AudioVisualizer Component
This component uses an HTML canvas element to represent the audio waveform along a horizontal line. The canvas element is used for its flexibility and efficiency in rendering real-time visualizations, making it suitable for audio waveforms.
The visualization dynamically adjusts based on the amplitude of the captured audio, providing a real-time feedback loop for the user during recording. To do that, it watches the updateTrigger state variable exposed by useMediaRecorder to redraw the canvas on audio data changes.
Create a new file AudioVisualizer.vue in the app/components folder and add the following code to it:
The NoteRecorder component uses the useRecordings composable to manage the list of recordings, and to clear any used resources. Create a new file useRecordings.ts in the app/composables folder and add the following code to it:
You can define the Recording type definition in the shared/types/index.ts file. This allows for auto import of type definitions in both client & server sides (The intended purpose of the shared folder is for sharing common types & utils between the app & server). Also, while you’re at it, you can also define the Note type.
Now that we have all the pieces ready for the basic app, it is time to put everything together in a page. Delete the content of the home page (app/pages/index.vue), and put the following content to it:
<!-- app/pages/index.vue --><template><UContainerclass="h-screen flex justify-center items-center"><UCardclass="w-full max-h-full overflow-hidden max-w-4xl mx-auto":ui="{ body: 'h-[calc(100vh-4rem)] overflow-y-auto' }"
>
<template#header><spanclass="font-bold text-xl md:text-2xl">Voice Notes</span><UButtonicon="i-lucide-plus"@click="showNoteModal">
New Note
</UButton></template><divv-if="notes?.length"class="space-y-4"><NoteCardv-for="note in notes":key="note.id":note="note"/></div><divv-elseclass="my-12 text-center text-gray-500 dark:text-gray-400 space-y-2"><h2class="text-2xl md:text-3xl">No notes created</h2><p>Get started by creating your first note</p></div></UCard></UContainer></template><scriptsetuplang="ts">import{LazyNoteEditorModal}from"#components";const{data:notes,refresh}=awaituseFetch("/api/notes");constmodal=useModal();constshowNoteModal=()=>{modal.open(LazyNoteEditorModal,{onNewNote:refresh,});};watch(modal.isOpen,(newState)=>{if (!newState){modal.reset();}});</script>
On this page we’re doing the following:
Fetch the list of existing notes from the database and display them using the NoteCard component
Shows a new note button which when clicked opens the NoteEditorModal. On successful note creation the refresh function is called to refetch the notes
The modal state is reset on closure to ensure a clean slate for the next note creation
The cards and modals headers/footers used in the app follow a global style that is defined in the app config file. Centralizing styles in the app configuration ensures consistent theming and reduces redundancy across components.
Create a new file app.config.ts inside the app folder, and add the following to it:
This component displays the note text and the attached audio recordings of a note. The note text is clamped to 3 lines with a show more/less button to show/hide rest of the text. Text clamping ensures that the UI remains clean and uncluttered, while the show more/less button gives users full control over note visibility.
Create a new file NoteCard.vue in the app/components folder, and add the following code to it:
And we are done here. Try running the application and create some notes. You should be able to create notes, add multiple recordings to the same note etc. Everything should be working now, or is it?
Try playing the audio recordings of the saved notes, are these playable?
Serving the Audio Recordings
We can’t play the audio recordings because these are saved in R2 (local disk in dev mode), and nowhere we are serving these files. It is time to fix that.
If you look at the /api/notes code, we save the audio urls/pathnames with an audio prefix
The reason to do so was to serve all audio recordings through an /audio path. Create a new file […pathname].get.ts in the server/routes/audio folder and add the following to it:
What we’ve done above is to catch all requests to the /audio path (by using the wildcard […pathname] in the filename), and serve the requested recording from the storage using hubBlob.
With this, the frontend is complete, and all functionalities should now work seamlessly.
Further Enhancements
What you’ve created here is a basic version of the application—with all must-have features—that you saw in the beginning of the article. You can further refine the app and take it closer to the demo by:
What you’ve created here is a solid foundation for the application, complete with the core features introduced earlier. To further enhance the app and bring it closer to the full demo version, consider implementing the following features:
Adding a settings page to save post processing settings.
Handle post processing in the /transcribe api route.
Allowing edit/delete of saved notes.
Experimenting with additional features that fit your use case or user needs.
If you get stuck while implementing these features, do not hesitate to look at the application source code. The complete source code of the final application is shared at the end of the article.
Deploying the Application
You can deploy the application using either the NuxtHub admin dashboard or through the NuxtHub CLI.
You can find the source code of Vhisper application on GitHub. The source code includes all the features discussed in this article, along with additional configurations and optimizations shown in the demo.
Voice Notes with AI transcriptions and post processing
Vhisper - In-browser Voice Notes
Vhisper is a serverless voice notes application built with Nuxt 3 that leverages various Cloudflare services through NuxtHub for it to work. It allows users to record voice notes, transcribe and post process them using AI, and manage them through a simple, intuitive interface.
Congratulations! You've built a powerful application that records and transcribes audio, stores recordings, and manages notes with an intuitive interface. Along the way you’ve touched upon various aspects of Nuxt, NuxtHub and Cloudflare services. As you continue to refine and expand Vhisper, consider exploring additional features and optimizations to further enhance its functionality and user experience. Keep experimenting and innovating, and let this project be a stepping stone to even more ambitious endeavors.
Thank you for sticking with me until the end! I hope you’ve picked up some new concepts along the way. I’d love to hear what you learned or any thoughts you have in the comments section. Your feedback is not only valuable to me, but to the entire developer community exploring this exciting field.
Until next time!
Keep adding the bits and soon you'll have a lot of bytes to share with the world.