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.tssuccess_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):

  1. Price ID (STRIPE_METERED_PRICE): Stripe Dashboard → Products → Create product (“YouTube to Blog”, price $19/month) → under Pricing model choose Metered usage with the meter video_generated → Create price → copy the price_1... ID.
  2. Secret key (STRIPE_SECRET_KEY): Dashboard → Developers → API keys → copy the sk_test_... key (or sk_live_... in production).
  3. Webhook secret (STRIPE_WEBHOOK_SECRET): Dashboard → Developers → Webhooks → Add endpoint → URL https://YOUR-DOMAIN.com/api/webhooks/stripe → select event billing.meter.error.reported (and any events you listen to) → after creation, reveal the whsec_... 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, '&lt;')}</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.

Related guides