DEV Community

Cover image for I Built a Free Article-to-Audio Converter!
Digiwares
Digiwares

Posted on

I Built a Free Article-to-Audio Converter!

I Built a Free Article-to-Audio Converter in a Weekend

My "read later" list was out of control. Hundreds of articles I'd never get to. So I built Sornic — paste any article URL, get audio in seconds.

The Problem

I wanted to catch up on articles while commuting, cooking, or working out. But:

  • Pocket/Instapaper just pile up unread
  • Most TTS apps sound robotic
  • Browser extensions are clunky

I wanted something dead simple: URL in, audio out.

The Stack

  • Next.js 14 (App Router)
  • OpenAI TTS API (natural voices)
  • Vercel (hosting + serverless functions)
  • Upstash Redis (rate limiting)
  • Tailwind CSS (styling)

How It Works

1. Article Extraction

When you paste a URL, the server fetches the page and extracts the article content using Mozilla's Readability library (same one Firefox uses for Reader View).

const dom = new JSDOM(html, { url });
const reader = new Readability(dom.window.document);
const article = reader.parse();
Enter fullscreen mode Exit fullscreen mode

For JS-heavy sites that don't work with simple fetch, I fall back to Firecrawl.

2. Content Cleanup

Raw extracted text often includes navigation, ads, "Subscribe now!" prompts. I use Claude Haiku to clean it up:

const response = await anthropic.messages.create({
  model: 'claude-haiku-4-20250514',
  messages: [{
    role: 'user',
    content: `Clean this article for text-to-speech.
              Remove nav, ads, CTAs. Keep only the article body.
              ${rawText}`
  }]
});
Enter fullscreen mode Exit fullscreen mode

3. Text-to-Speech

OpenAI's TTS API has a 4096 character limit, so I chunk long articles:

function splitTextIntoChunks(text: string, maxLength: number): string[] {
  // Break at sentence boundaries when possible
  const sentenceMatch = remaining.slice(0, maxLength).match(/.*[.!?]\s/s);
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Then generate audio for each chunk and concatenate:

for (const chunk of chunks) {
  const mp3Response = await openai.audio.speech.create({
    model: 'tts-1',
    voice: 'nova',
    input: chunk,
    speed: 1.0
  });
  audioBuffers.push(await mp3Response.arrayBuffer());
}
Enter fullscreen mode Exit fullscreen mode

4. Rate Limiting

Free tier = 5 articles/day per IP. Using Upstash Redis:

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.fixedWindow(5, '24h')
});
Enter fullscreen mode Exit fullscreen mode

Challenges

1. Vercel Timeouts

Default is 10 seconds. Long articles can take 30-60 seconds to process. Fixed with:

// vercel.json
{
  "functions": {
    "app/api/**/*.ts": { "maxDuration": 60 }
  }
}
Enter fullscreen mode Exit fullscreen mode

2. ESM/CommonJS Conflicts

jsdom v27 broke on Vercel due to ESM issues. Downgraded to v24:

npm install jsdom@24.1.3
Enter fullscreen mode Exit fullscreen mode

3. Sites Blocking Scraping

Some sites block server-side requests. Firecrawl handles these as a fallback — it uses headless browsers and handles anti-bot measures.

Cost Breakdown

Per article (~2000 words):

  • OpenAI TTS: ~$0.03
  • Claude Haiku cleanup: ~$0.001
  • Vercel/Upstash: Free tier

At 5 free articles/user/day, costs stay manageable with the rate limit.

What's Next

  • Download as MP3
  • Browser extension
  • Playlist/queue feature
  • Premium tier with more articles

Try It

sornic.com — no signup required.

Drop a URL, pick a voice, hit play. Would love feedback on what features would make it more useful.


Tags: webdev, nextjs, openai, javascript

Top comments (0)