Build a YouTube-to-Blog SaaS
Build a SaaS that converts YouTube videos into SEO-ready blog posts: transcript, LLM draft, editor and Stripe billing, deployed to a VPS.
Build and launch a SaaS that turns any YouTube video into a structured, SEO-ready blog post — from transcript to paid product.
What we’re building
flowchart LR
User --> Next[Next.js app]
Next --> YT[YouTube transcript API]
Next --> API[LLM API]
Next --> PG[(PostgreSQL)]
Next --> Stripe[Stripe]
What you’ll learn
- Transcript extraction without violating ToS (user-provided URL, public content only)
- LLM templating that produces publishable drafts
- Metered usage with Stripe (per video, not seats), with signature-verified webhooks
- A route handler that validates input and never double-bills
Prerequisites
- Node.js 22+
- PostgreSQL 17 (local via Docker, or any managed Postgres)
- Stripe account
- LLM API key
- A VPS with 2 GB RAM (deploy step)
1. Create the project
npx create-next-app@latest yt2blog --ts --app --use-npm
cd yt2blog
npm i stripe openai pg youtube-transcript
npm i -D @types/pg
2. Database schema and local Postgres
schema.sql:
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL
);
CREATE TABLE jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
video_url TEXT NOT NULL,
draft TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
docker-compose.yml (root of the project — the db service only; the app and Caddy are added in section 8):
services:
db:
image: postgres:17
environment:
POSTGRES_PASSWORD: dev
POSTGRES_DB: yt2blog
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Apply the schema and manage data from lib/db.ts:
// lib/db.ts
import { Pool } from 'pg';
export const pool = new Pool({
connectionString:
process.env.DATABASE_URL ?? 'postgres://postgres:dev@localhost:5432/yt2blog',
});
docker compose up -d db
psql "$DATABASE_URL" -f schema.sql
To reset during development: docker compose down -v && docker compose up -d db and re-apply the schema.
3. Get the transcript
The transcript provider is isolated behind an interface so a YouTube DOM change is a single-file fix, and failures return a typed error:
// lib/transcript.ts
import { YoutubeTranscript } from 'youtube-transcript';
export async function fetchTranscript(url: string): Promise<string> {
const list = await YoutubeTranscript.fetchTranscript(url);
if (!list.length) {
throw new Error('No transcript found for this video (subtitles disabled?).');
}
return list.map((s) => s.text).join(' ');
}
Ethics note: only allow videos the user has upload access to, or clearly public content. This is a content tool, not a scraping tool. Validate the URL before calling the provider (see section 5).
4. The LLM transform
lib/generate.ts — a fixed post-structure template, explicit failure handling:
import OpenAI from 'openai';
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const TEMPLATE = `You are a technical content editor.
Turn the transcript into a blog post:
- H1 from the core topic
- Intro (2-3 sentences, states the outcome)
- 5-8 H2 sections
- Code blocks and bullets where the speaker describes technical steps
- Conclusion
Transcript:
{prompt}`;
export async function generatePost(transcript: string): Promise<string> {
try {
const res = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: TEMPLATE.replace('{prompt}', transcript) }],
temperature: 0.4,
});
const content = res.choices[0]?.message.content;
if (!content) throw new Error('Empty completion from the LLM API.');
return content;
} catch (err) {
throw new Error(`LLM call failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
5. The generate API route
app/api/generate/route.ts — validates the URL, runs the pipeline, persists the job, and records the metered Stripe event only after the LLM call succeeds:
import { NextRequest, NextResponse } from 'next/server';
import { fetchTranscript } from '@/lib/transcript';
import { generatePost } from '@/lib/generate';
import { recordUse } from '@/lib/billing';
import { pool } from '@/lib/db';
export async function POST(req: NextRequest) {
let body: { videoUrl?: string; userId?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 });
}
const { videoUrl, userId } = body;
if (typeof videoUrl !== 'string' || !/^https:\/\/(www\.)?youtube\.com\/watch\?v=.+/.test(videoUrl)) {
return NextResponse.json({ error: 'Valid YouTube watch URL required.' }, { status: 400 });
}
if (typeof userId !== 'string' || !userId) {
return NextResponse.json({ error: 'userId required.' }, { status: 401 });
}
try {
const transcript = await fetchTranscript(videoUrl);
const draft = await generatePost(transcript);
const result = await pool.query(
`INSERT INTO jobs (user_id, video_url, draft, status)
VALUES ($1, $2, $3, 'done') RETURNING id`,
[userId, videoUrl, draft],
);
await recordUse(userId);
return NextResponse.json({ jobId: result.rows[0].id, draft }, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Generation failed.';
const status = /transcript/i.test(message) ? 422 : 502;
return NextResponse.json({ error: message }, { status });
}
}
The order matters: recordUse runs only after the database insert succeeds, and only once per job — that is the “no double billing” guarantee.
6. Metered billing with Stripe
Charge per video with a metered price. lib/billing.ts — success_url is built from the request origin, never a hardcoded domain:
// lib/billing.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function createCheckout(email: string, origin: string) {
return stripe.checkout.sessions.create({
customer_email: email,
line_items: [{ price: process.env.STRIPE_METERED_PRICE, quantity: 1 }],
mode: 'subscription',
success_url: `${origin}/dashboard`,
cancel_url: `${origin}/pricing`,
});
}
export async function recordUse(user: string) {
await stripe.billing.meterEvents.create({
event_name: 'video_generated',
payload: { value: '1', stripe_customer_id: user },
});
}
Where to get the IDs (Stripe dashboard):
- Price ID (
STRIPE_METERED_PRICE): Stripe Dashboard → Products → Create product (“YouTube to Blog”, price$19/month) → under Pricing model choose Metered usage with the metervideo_generated→ Create price → copy theprice_1...ID. - Secret key (
STRIPE_SECRET_KEY): Dashboard → Developers → API keys → copy thesk_test_...key (orsk_live_...in production). - Webhook secret (
STRIPE_WEBHOOK_SECRET): Dashboard → Developers → Webhooks → Add endpoint → URLhttps://YOUR-DOMAIN.com/api/webhooks/stripe→ select eventbilling.meter.error.reported(and any events you listen to) → after creation, reveal thewhsec_...signing secret.
The checkout route (app/api/checkout/route.ts):
import { NextRequest, NextResponse } from 'next/server';
import { createCheckout } from '@/lib/billing';
export async function POST(req: NextRequest) {
const origin = req.headers.get('origin') ?? process.env.NEXT_PUBLIC_APP_URL ?? '';
let body: { email?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 });
}
if (typeof body.email !== 'string' || !body.email.includes('@')) {
return NextResponse.json({ error: 'Valid email required.' }, { status: 400 });
}
try {
const session = await createCheckout(body.email, origin);
return NextResponse.json({ url: session.url });
} catch (err) {
return NextResponse.json(
{ error: `Stripe checkout failed: ${err instanceof Error ? err.message : ''}` },
{ status: 502 },
);
}
}
Metered billing means you don’t track usage yourself — Stripe meters each video_generated event and bills the customer.
7. The editor page
app/dashboard/[jobId]/page.tsx — a real client page that loads the job, saves the draft, copies Markdown, and exports a .docx:
// app/dashboard/[jobId]/page.tsx
'use client';
import { useEffect, useState } from 'react';
export default function Editor({ params }: { params: { jobId: string } }) {
const [text, setText] = useState('');
const [saved, setSaved] = useState(false);
useEffect(() => {
fetch(`/api/jobs/${params.jobId}`)
.then((r) => (r.ok ? r.json() : Promise.reject(new Error('load failed'))))
.then((d) => setText(d.draft))
.catch(() => setText('Failed to load draft.'));
}, [params.jobId]);
async function save() {
const res = await fetch(`/api/jobs/${params.jobId}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ draft: text }),
});
setSaved(res.ok);
}
function copyMarkdown() {
navigator.clipboard.writeText(text);
}
function exportDocx() {
const html = `<h1>Your post</h1><pre>${text.replace(/</g, '<')}</pre>`;
const blob = new Blob(['\ufeff', html], { type: 'application/msword' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'post.doc';
a.click();
URL.revokeObjectURL(a.href);
}
return (
<main style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={30}
style={{ width: '100%', fontFamily: 'monospace' }}
/>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={save}>Save draft</button>
<button onClick={copyMarkdown}>Copy Markdown</button>
<button onClick={exportDocx}>Export .docx</button>
{saved && <span>Saved</span>}
</div>
</main>
);
}
The two supporting routes (app/api/jobs/[jobId]/route.ts) — GET loads, PUT saves with validation:
import { NextRequest, NextResponse } from 'next/server';
import { pool } from '@/lib/db';
type Ctx = { params: Promise<{ jobId: string }> };
export async function GET(_req: NextRequest, { params }: Ctx) {
const { jobId } = await params;
const result = await pool.query('SELECT id, draft, status FROM jobs WHERE id = $1', [jobId]);
if (result.rowCount === 0) {
return NextResponse.json({ error: 'Job not found.' }, { status: 404 });
}
return NextResponse.json(result.rows[0]);
}
export async function PUT(req: NextRequest, { params }: Ctx) {
const { jobId } = await params;
let body: { draft?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 });
}
if (typeof body.draft !== 'string') {
return NextResponse.json({ error: 'draft (string) required.' }, { status: 400 });
}
const result = await pool.query(
'UPDATE jobs SET draft = $1, status = $2 WHERE id = $3 RETURNING id',
[body.draft, 'edited', jobId],
);
if (result.rowCount === 0) {
return NextResponse.json({ error: 'Job not found.' }, { status: 404 });
}
return NextResponse.json({ ok: true });
}
8. Run locally
docker compose up -d db
psql "$DATABASE_URL" -f schema.sql
export DATABASE_URL=postgres://postgres:dev@localhost:5432/yt2blog
export OPENAI_API_KEY=your_key_here
export STRIPE_SECRET_KEY=sk_test_...
export STRIPE_METERED_PRICE=price_1...
export STRIPE_WEBHOOK_SECRET=whsec_...
export NEXT_PUBLIC_APP_URL=http://localhost:3000
npm run dev
Test the whole flow: open http://localhost:3000, create a job from any public YouTube URL, and confirm the draft appears in the editor.
9. Deploy to a VPS
Extend docker-compose.yml with the app and Caddy, and add a Dockerfile:
Dockerfile:
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS run
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
docker-compose.yml (full — replace the dev-only version):
services:
db:
image: postgres:17
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: yt2blog
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
app:
build: .
environment:
DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/yt2blog
OPENAI_API_KEY: ${OPENAI_API_KEY}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
STRIPE_METERED_PRICE: ${STRIPE_METERED_PRICE}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
NEXT_PUBLIC_APP_URL: https://YOUR-DOMAIN.com
depends_on:
- db
restart: unless-stopped
caddy:
image: caddy:2
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
restart: unless-stopped
volumes:
pgdata:
caddy_data:
Caddyfile:
YOUR-DOMAIN.com {
reverse_proxy app:3000
}
Then on the VPS:
git clone <your-repo> yt2blog && cd yt2blog
cp .env.example .env # fill every value
docker compose up -d --build
Point the DNS A record of YOUR-DOMAIN.com at the VPS IP. Caddy issues the HTTPS certificate automatically, and Stripe webhooks hit https://YOUR-DOMAIN.com/api/webhooks/stripe. Apply the schema once: psql "$DATABASE_URL" -f schema.sql.
.env.example (commit this; never commit .env):
POSTGRES_PASSWORD=change-me
DATABASE_URL=postgres://postgres:change-me@localhost:5432/yt2blog
OPENAI_API_KEY=sk-...
STRIPE_SECRET_KEY=sk_test_...
STRIPE_METERED_PRICE=price_1...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_APP_URL=https://YOUR-DOMAIN.com
Environment variables
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
yes | PostgreSQL connection string. Locally postgres://postgres:dev@localhost:5432/yt2blog; on the VPS it points at the db service. |
OPENAI_API_KEY |
yes | LLM API key used for embedding and generation (platform.openai.com → API keys). |
STRIPE_SECRET_KEY |
yes | Stripe secret key (sk_test_... or sk_live_...). |
STRIPE_METERED_PRICE |
yes | Metered usage price_1... for the blog-post subscription. |
STRIPE_WEBHOOK_SECRET |
yes | whsec_... signing secret for the /api/webhooks/stripe endpoint. |
NEXT_PUBLIC_APP_URL |
no | Public origin, used as fallback for absolute URLs when no Origin header is present. |
Cost (assumptions: 100 active users, 300 videos/mo)
| Service | Monthly cost |
|---|---|
| VPS 2 GB (Hetzner CX22) | $4.49 |
| LLM API (gpt-4o-mini, ~15 min videos) | $30–50 |
| Stripe (1.5% + $0.30/tx) | ~$30 |
| Email (Resend) | $0 (free tier) |
| Total | ≈ $70–85/month |
At $19/mo per user, 100 customers covers this 10x.
Production checklist
- Only public videos; block private/unavailable URLs
- Usage metering happens exactly once per paid generation (after success, see section 5)
- Drafts autosaved to PostgreSQL (PUT route)
- Rate limit per user (e.g. 10/mo on the free trial)
- Webhook signature verified with
stripe.webhooks.constructEvent - Daily backup of users + jobs (
pg_dump) - Budget alert at 80% of each service
Common problems
- Transcript API breaks: YouTube changes its DOM; the provider is isolated in
lib/transcript.ts— swap it and monitor failures. - Double billing: the meter event is recorded after the DB insert succeeds, exactly once per job.
- Low-quality drafts: temperature 0.4, and a fixed post-structure template beats free generation.
- Empty completion from the LLM: caught and surfaced as a 502 instead of a silent failure.
Improvements
- Batch mode: N videos → N drafts in one session
- WordPress/Notion one-click publish
- Auto-hook your own YouTube channel → blog
- Queue transcript + LLM steps with a worker for very long videos
Conclusion
The value chain is short: transcript → LLM draft → editable copy → bill. With Next.js, an LLM API and Stripe metered billing, a weekend of work becomes a paid, recurring product.