DEV Community

Sébastien LOPEZ
Sébastien LOPEZ

Posted on

Automate SEO keyword tagging with Node.js for under $0.01 per page

If you run a blog, a content site, or any kind of publication, you know the pain: every post needs tags, categories, and meta keywords. Done manually, it's tedious. Done with an LLM subscription, it's overkill — you're paying a monthly fee for a task that takes milliseconds per page.

This tutorial shows how to build an automatic SEO keyword tagger in Node.js using a pay-per-use keyword extraction API. Total cost: under $0.01 per page. No subscription required.

What we're building

A Node.js script that:

  1. Reads URLs from a list (or a sitemap)
  2. Fetches the page text
  3. Extracts the top 10 keywords via API
  4. Outputs a JSON file mapping URL → keywords (ready to push to your CMS or meta tags)

The API

We'll use TextAI API — a pay-per-use REST API with USDC micropayments on Solana.

Pricing for keyword extraction: 5 credits per call. 1 USDC = 1,000 credits, so that's $0.005 per call. Under $0.01 per page.

New accounts get 100 free credits instantly — no credit card, just a POST request.

Setup

npm init -y
npm install node-fetch
Enter fullscreen mode Exit fullscreen mode

Get your free API key:

curl -X POST https://textai-api.overtek.deno.net/keys/create
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "apiKey": "tai_xxxxxxxxxxxx",
  "credits": 100,
  "message": "100 free demo credits included"
}
Enter fullscreen mode Exit fullscreen mode

Save the key:

export TEXTAI_API_KEY=tai_xxxxxxxxxxxx
Enter fullscreen mode Exit fullscreen mode

The tagger script

// seo-tagger.js
import fetch from 'node-fetch';

const API_KEY = process.env.TEXTAI_API_KEY;
const API_URL = 'https://textai-api.overtek.deno.net';

// Your pages — swap this for a sitemap parser or CMS API call
const pages = [
  { url: 'https://example.com/blog/how-to-build-rest-apis', text: 'REST APIs are the backbone of modern web apps. In this guide we cover routing, authentication, rate limiting...' },
  { url: 'https://example.com/blog/node-performance-tips', text: 'Node.js performance optimization starts with event loop management. Avoid blocking I/O, use streams...' },
  { url: 'https://example.com/blog/javascript-closures', text: 'A closure is the combination of a function and the lexical environment within which that function was declared...' },
];

async function extractKeywords(text, maxKeywords = 10) {
  const response = await fetch(`${API_URL}/keywords`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-API-Key': API_KEY,
    },
    body: JSON.stringify({ text, max_keywords: maxKeywords }),
  });

  if (!response.ok) {
    const err = await response.json();
    throw new Error(`API error: ${err.error || response.status}`);
  }

  return response.json();
}

async function tagAllPages(pages) {
  const results = [];

  for (const page of pages) {
    try {
      const data = await extractKeywords(page.text);
      results.push({
        url: page.url,
        keywords: data.keywords,
        credits_used: data.credits_used,
        credits_remaining: data.credits_remaining,
      });
      console.log(`✓ ${page.url} → [${data.keywords.slice(0,5).join(', ')}...]`);
    } catch (err) {
      console.error(`✗ ${page.url}: ${err.message}`);
      results.push({ url: page.url, keywords: [], error: err.message });
    }
  }

  return results;
}

// Run
const tagged = await tagAllPages(pages);
console.log('\n--- Results ---');
console.log(JSON.stringify(tagged, null, 2));

// Total cost estimate
const totalCalls = tagged.filter(r => !r.error).length;
const totalCredits = totalCalls * 5;
console.log(`\nTotal: ${totalCalls} pages, ${totalCredits} credits (~$${(totalCredits / 1000).toFixed(4)} USDC)`);
Enter fullscreen mode Exit fullscreen mode

Run it:

node --experimental-vm-modules seo-tagger.js
Enter fullscreen mode Exit fullscreen mode

Example output:

 https://example.com/blog/how-to-build-rest-apis  [REST API, authentication, routing, web apps, rate limiting...]
 https://example.com/blog/node-performance-tips  [Node.js, event loop, performance, I/O, streams...]
 https://example.com/blog/javascript-closures  [closure, lexical scope, JavaScript, function, environment...]

--- Results ---
[
  {
    "url": "https://example.com/blog/how-to-build-rest-apis",
    "keywords": ["REST API", "authentication", "routing", "web apps", "rate limiting", "HTTP", "JSON", "middleware", "endpoint", "security"],
    "credits_used": 5,
    "credits_remaining": 95
  },
  ...
]

Total: 3 pages, 15 credits (~$0.0150 USDC)
Enter fullscreen mode Exit fullscreen mode

Push keywords to your CMS

If you're on WordPress, pipe the output to the REST API:

// After tagAllPages(), for each result:
async function updateWordPressTags(postId, keywords, wpApiUrl, wpToken) {
  await fetch(`${wpApiUrl}/wp/v2/posts/${postId}`, {
    method: 'PUT',
    headers: {
      'Authorization': `Bearer ${wpToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      meta: { _yoast_wpseo_focuskw: keywords[0], seo_keywords: keywords.join(', ') }
    }),
  });
}
Enter fullscreen mode Exit fullscreen mode

For Ghost CMS:

async function updateGhostTags(postSlug, keywords, ghostApiUrl, ghostKey) {
  const tags = keywords.slice(0, 5).map(k => ({ name: k }));
  await fetch(`${ghostApiUrl}/ghost/api/admin/posts/slug/${postSlug}/`, {
    method: 'PUT',
    headers: { 'Authorization': `Ghost ${ghostKey}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ posts: [{ tags }] }),
  });
}
Enter fullscreen mode Exit fullscreen mode

Scale to an entire site

For a 1,000-page blog:

// Add rate limiting to be polite to the API
import { setTimeout as sleep } from 'timers/promises';

async function tagAllPagesThrottled(pages, concurrency = 5, delayMs = 100) {
  const results = [];
  for (let i = 0; i < pages.length; i += concurrency) {
    const batch = pages.slice(i, i + concurrency);
    const batchResults = await Promise.all(batch.map(p => extractKeywords(p.text)));
    results.push(...batchResults);
    await sleep(delayMs); // 100ms between batches
    console.log(`Progress: ${Math.min(i + concurrency, pages.length)}/${pages.length}`);
  }
  return results;
}
Enter fullscreen mode Exit fullscreen mode

Cost for 1,000 pages: 1,000 × 5 credits = 5,000 credits = $5 USDC.

That's less than a single monthly subscription fee, and you only pay when you run it.

Credits and top-up

Check your balance any time:

curl -H "X-API-Key: $TEXTAI_API_KEY" https://textai-api.overtek.deno.net/credits
Enter fullscreen mode Exit fullscreen mode

When you need more credits, send USDC to the wallet address in the response. 1 USDC = 1,000 credits, auto-credited within seconds on Solana devnet.

Why pay-per-use beats subscriptions for this

Pay-per-use (TextAI) Subscription LLM
1 page $0.005 ~$0.50 (monthly min)
100 pages $0.50 ~$0.50
1,000 pages $5.00 ~$0.50–$20
Idle month $0 $20–$100

The break-even is around 100 pages/month. Below that, pay-per-use wins. Above 10,000 pages/month, a subscription might be cheaper — but you're probably a large publication at that point.

Try it

Free credits, no credit card: https://textai-api.overtek.deno.net

Full API docs at the same URL. The keyword extraction endpoint also returns a relevance score for each keyword, useful for ranking which tags matter most for a given post.

If you build something with this, drop a comment — curious what use cases people find for bulk keyword extraction.

Top comments (0)