LLM Guides

How to Identify Topics in Text Content with JavaScript

Jul 01, 2026

When people say “identify topics in text,” they can mean a few different things.

Sometimes they want keywords:

refund, invoice, payment, subscription

Sometimes they want broader topics:

billing issue

And sometimes they want a full tagging system:

{

  “topics”: [“billing”, “account issue”],

  “keywords”: [“refund”, “subscription”, “charged twice”],

  “confidence”: 0.82

}

So before we start writing code, let’s make the goal clear.

In this guide, we’ll build a JavaScript topic identifier that can:

  1. Clean and normalize text.
  2. Extract useful keywords.
  3. Score topics with rules.
  4. Return one or multiple topic labels.
  5. Use TF-IDF when you have many documents.
  6. Use zero-shot classification when you want flexible labels.
  7. Connect the whole thing into a larger AI workflow with LLMAPI.

We’ll keep it practical. You can use this for blog tags, support tickets, customer feedback, reviews, emails, chat messages, survey responses, or user-generated content.

Why we can write this guide

We’ve spent around 6 years working with AI APIs, NLP tools, text classification, content workflows, and automation systems. We also researched current JavaScript NLP libraries and topic modeling methods for this article.

The main thing we’ve learned is pretty simple: topic detection works best when you match the method to the job.

If you need fast routing, keyword rules may be enough. If you have thousands of documents, TF-IDF or embeddings make more sense. If your topics change often, zero-shot classification or an LLM workflow can save a lot of manual work.

That is why this guide starts small and then builds up.

What does “topic identification” actually mean?

Let’s say a user sends this message:

I was charged twice this month and I need someone to fix my subscription.

Your app could extract:

{

  “topic”: “billing”,

  “keywords”: [“charged twice”, “subscription”],

  “action”: “route_to_billing_team”

}

That sounds easy, yeah? But real text gets messy fast.

Users write things like:

why did yall take money from my card again lol

There is no word “billing” here. There is no word “subscription” either. But the topic is still billing.

That is why topic identification usually uses one of these approaches:

MethodBest for
Keyword rulesSimple routing and clear categories
Weighted scoringMessages with overlapping topics
TF-IDFFinding important words across many documents
Topic modelingDiscovering hidden themes in large datasets
Zero-shot classificationLabeling text with custom topics without training
LLM-based classificationFlexible, messy, natural-language topic detection

We’ll use JavaScript for the actual implementation and explain when each method makes sense.

Which method should you start with?

Here is the honest, practical answer.

Your situationStart with
You have 5-20 known topicsKeyword scoring
You need to route support ticketsWeighted rules + confidence
You have many articles or reviewsTF-IDF
You want to discover topics automaticallyTopic modeling or embeddings
Your labels change all the timeZero-shot classification
Text is messy and indirectLLM-based classification
You need a production workflowRules + model + human review for uncertain cases

For version one, rules are usually fine.

I know rules sound boring, but boring is sometimes beautiful. They are cheap, fast, easy to debug, and your team can understand why a text got a topic. Once rules start getting too messy, then you bring in ML or LLMs.

Step 1: Create a simple topic list

Start with topics your app actually needs.

For example, a SaaS support tool may use:

const topics = {
  billing: [
    "refund",
    "charged",
    "payment",
    "invoice",
    "subscription",
    "receipt",
    "price",
    "pricing"
  ],
  account: [
    "login",
    "password",
    "account",
    "sign in",
    "locked",
    "reset",
    "email"
  ],
  bug: [
    "bug",
    "crash",
    "broken",
    "error",
    "not working",
    "glitch",
    "freeze"
  ],
  feature_request: [
    "feature",
    "add",
    "request",
    "suggestion",
    "would be nice",
    "can you build"
  ]
};

This gives your app a basic map:

Words and phrases → Topic labels

A good first topic list should be small. Start with 4 to 8 topics. When people start with 40 topics, they usually create overlap and confusion before the system has even seen real data.

Step 2: Normalize the text

Users do not write like clean datasets.

They use caps, emojis, punctuation, typos, slang, and random spacing. So first, clean the text.

function normalizeText(text) {
  return text
    .toLowerCase()
    .replace(/[^\w\s]/g, " ")
    .replace(/\s+/g, " ")
    .trim();
}

Try it:

const message = "WHY was I charged twice?? 😭";

console.log(normalizeText(message));

Output:

why was i charged twice

This makes matching easier.

You can improve normalization later with stemming or lemmatization. Libraries like Natural support tokenizing, stemming, classification, TF-IDF, string similarity, and other NLP features for Node.js. That matters when “charge,” “charged,” and “charging” should point to the same topic.

Step 3: Match topics with simple rules

Now let’s build the first version.

function identifyTopic(text, topics) {
  const normalizedText = normalizeText(text);

  for (const [topic, keywords] of Object.entries(topics)) {
    const matched = keywords.some((keyword) =>
      normalizedText.includes(keyword.toLowerCase())
    );

    if (matched) {
      return topic;
    }
  }

  return "unknown";
}

Test it:

const message = "I was charged twice for my subscription.";

console.log(identifyTopic(message, topics));

Output:

billing

This works for a tiny demo. The issue is that it returns the first matching topic. Real messages can mention several things at once.

Example:

I can’t log in, and I was charged twice.

That is both account and billing.

So let’s make it smarter.

Step 4: Score each topic

Instead of returning the first match, we’ll score every topic.

function scoreTopics(text, topics) {
  const normalizedText = normalizeText(text);
  const scores = {};

  for (const [topic, keywords] of Object.entries(topics)) {
    scores[topic] = 0;

    for (const keyword of keywords) {
      if (normalizedText.includes(keyword.toLowerCase())) {
        scores[topic] += 1;
      }
    }
  }

  return scores;
}

Now choose the top topic:

function identifyTopTopic(text, topics) {
  const scores = scoreTopics(text, topics);

  const sortedTopics = Object.entries(scores).sort(
    (a, b) => b[1] - a[1]
  );

  const [bestTopic, bestScore] = sortedTopics[0];

  if (bestScore === 0) {
    return {
      topic: "unknown",
      confidence: 0,
      scores
    };
  }

  const totalScore = Object.values(scores).reduce(
    (sum, score) => sum + score,
    0
  );

  return {
    topic: bestTopic,
    confidence: Number((bestScore / totalScore).toFixed(2)),
    scores
  };
}

Test it:

console.log(
  identifyTopTopic(
    "I can't log in and I was charged twice.",
    topics
  )
);

Example output:

{
  topic: "billing",
  confidence: 0.5,
  scores: {
    billing: 1,
    account: 1,
    bug: 0,
    feature_request: 0
  }
}

The confidence is low because the message has two topics. That is useful. Your app can now say, “Hmm, this one may need multi-topic routing.”

Step 5: Use weighted keywords

Some phrases are stronger than others.

For example, “refund” is a stronger billing signal than “price.” “Crash” is a stronger bug signal than “not working.”

Let’s use weights.

const weightedTopics = {
  billing: {
    refund: 4,
    charged: 4,
    payment: 3,
    invoice: 3,
    subscription: 2,
    receipt: 2,
    price: 1,
    pricing: 1
  },
  account: {
    login: 3,
    password: 4,
    account: 2,
    "sign in": 3,
    locked: 4,
    reset: 3,
    email: 1
  },
  bug: {
    bug: 3,
    crash: 5,
    broken: 4,
    error: 3,
    "not working": 4,
    glitch: 3,
    freeze: 4
  },
  feature_request: {
    feature: 2,
    add: 2,
    request: 2,
    suggestion: 2,
    "would be nice": 4,
    "can you build": 5
  }
};

Now update the scoring function:

function scoreWeightedTopics(text, topics) {
  const normalizedText = normalizeText(text);
  const scores = {};

  for (const [topic, keywords] of Object.entries(topics)) {
    scores[topic] = 0;

    for (const [keyword, weight] of Object.entries(keywords)) {
      if (normalizedText.includes(keyword.toLowerCase())) {
        scores[topic] += weight;
      }
    }
  }

  return scores;
}

And return the result:

function identifyWeightedTopic(text, topics, threshold = 0.6) {
  const scores = scoreWeightedTopics(text, topics);

  const sortedTopics = Object.entries(scores).sort(
    (a, b) => b[1] - a[1]
  );

  const [bestTopic, bestScore] = sortedTopics[0];

  if (bestScore === 0) {
    return {
      topic: "unknown",
      confidence: 0,
      action: "review",
      scores
    };
  }

  const totalScore = Object.values(scores).reduce(
    (sum, score) => sum + score,
    0
  );

  const confidence = Number((bestScore / totalScore).toFixed(2));

  return {
    topic: bestTopic,
    confidence,
    action: confidence >= threshold ? "auto_tag" : "review",
    scores
  };
}

Test it:

const text = "The app crashes every time I open my invoice.";

console.log(identifyWeightedTopic(text, weightedTopics));

Example output:

{
  topic: "bug",
  confidence: 0.63,
  action: "auto_tag",
  scores: {
    billing: 3,
    account: 0,
    bug: 5,
    feature_request: 0
  }
}

This is better because the system understands that “crashes” is a stronger signal than “invoice.”

Step 6: Return multiple topics when needed

Some messages genuinely belong to more than one topic.

So let’s return every topic that passes a minimum score.

function identifyMultipleTopics(text, topics, minScore = 2) {
  const scores = scoreWeightedTopics(text, topics);

  const matches = Object.entries(scores)
    .filter(([, score]) => score >= minScore)
    .sort((a, b) => b[1] - a[1])
    .map(([topic, score]) => ({ topic, score }));

  return {
    topics: matches,
    hasMultipleTopics: matches.length > 1
  };
}

Test it:

console.log(
  identifyMultipleTopics(
    "I forgot my password and I was charged twice.",
    weightedTopics
  )
);

Output:

{
  topics: [
    { topic: "billing", score: 4 },
    { topic: "account", score: 4 }
  ],
  hasMultipleTopics: true
}

This is great for support systems. You can route the ticket to billing and still show that account access may also be involved.

Step 7: Extract keywords with winkNLP

Rules work when you already know the topics. But what if you want to discover useful words from text?

That is where keyword extraction helps.

winkNLP is a JavaScript NLP library that can tokenize text, detect sentences and entities, generate n-grams, remove stop words, normalize text, and process tokens. Its docs show that readDoc() turns text into a document with tokens, entities, and sentences, and out() returns normal JavaScript data types.

Install it:

npm install wink-nlp wink-eng-lite-web-model

Use it to extract useful tokens:

const winkNLP = require("wink-nlp");
const model = require("wink-eng-lite-web-model");
const its = require("wink-nlp/src/its.js");
const as = require("wink-nlp/src/as.js");

const nlp = winkNLP(model);

const text = `
Customers keep asking for refunds because invoices are confusing
and subscription prices are unclear.
`;

const doc = nlp.readDoc(text);

const words = doc
  .tokens()
  .filter((token) => {
    return token.out(its.type) === "word" && !token.out(its.stopWordFlag);
  })
  .out(its.lemma, as.freqTable);

console.log(words);

Example output:

[
  [ "customer", 1 ],
  [ "keep", 1 ],
  [ "ask", 1 ],
  [ "refund", 1 ],
  [ "invoice", 1 ],
  [ "confusing", 1 ],
  [ "subscription", 1 ],
  [ "price", 1 ],
  [ "unclear", 1 ]
]

This gives you the words that carry meaning. You can use them for tags, dashboards, topic hints, or content summaries.

Step 8: Use TF-IDF when you have many documents

Keyword frequency inside one text can be useful, but it can also be noisy.

If a word appears everywhere, it may not be special. For example, in support tickets, words like “help,” “issue,” and “please” may appear all the time. TF-IDF helps by giving more weight to terms that are important in one document but less common across the whole collection.

The Natural library supports TF-IDF in Node.js, so we can use it for document topic hints.

Install:

npm install natural

Use TF-IDF:

const natural = require("natural");
const TfIdf = natural.TfIdf;

const tfidf = new TfIdf();

tfidf.addDocument("I need a refund for my subscription invoice.");
tfidf.addDocument("The app crashes when I upload a PDF.");
tfidf.addDocument("Can you add dark mode to the dashboard?");

tfidf.listTerms(0).slice(0, 5).forEach((item) => {
  console.log(item.term, item.tfidf);
});

Example output:

refund 1.405
subscription 1.405
invoice 1.405
need 1

This helps your app find what makes each document different.

Research backs up why keyword weighting still matters. The paper Back to the Basics: A Quantitative Analysis of Statistical and Graph-Based Term Weighting Schemes for Keyword Extraction compared statistical and graph-based keyword extraction methods at scale. This fits our topic because it shows that simple term-weighting methods still deserve attention, especially when you need explainable keywords and do not want a heavy ML pipeline.

Step 9: Use zero-shot classification for flexible topics

Now let’s say you do not want to maintain keyword lists forever.

You want to give the model labels like:

[“billing”, “account access”, “bug report”, “feature request”]

And let the model decide which topic fits.

That is zero-shot classification.

Hugging Face Transformers.js supports zero-shot-classification, which means you can run compatible transformer models in JavaScript. The docs list zero-shot classification as a supported task and describe Transformers.js as a way to run transformer models in the browser or Node.js.

Install:

npm install @huggingface/transformers

Example:

import { pipeline } from "@huggingface/transformers";

const classifier = await pipeline(
  "zero-shot-classification",
  "Xenova/mobilebert-uncased-mnli"
);

const text = "I was charged twice for my monthly subscription.";

const labels = [
  "billing",
  "account access",
  "bug report",
  "feature request"
];

const result = await classifier(text, labels);

console.log(result);

Example output:

{
  sequence: "I was charged twice for my monthly subscription.",
  labels: ["billing", "account access", "feature request", "bug report"],
  scores: [0.91, 0.04, 0.03, 0.02]
}

This is useful when your labels change often or when users describe things in indirect ways.

Research on zero-shot classification supports this idea, but also adds a useful warning. The paper Evaluating Unsupervised Text Classification: Zero-shot and Similarity-based Approaches found that similarity-based methods can outperform zero-shot approaches in many settings, especially when the task is evaluated consistently across datasets. This fits our guide because it reminds us to test zero-shot classification against simpler methods. Fancy models can help, but they should earn their place with real results.

Step 10: Use topic modeling when you do not know the topics yet

Sometimes you do not have labels.

You may have 20,000 product reviews and want to know what people are talking about. In that case, you are not classifying into known topics. You are discovering themes.

This is topic modeling.

In JavaScript, you can build parts of this with embeddings and clustering, but many teams use Python libraries like BERTopic for deeper topic modeling. Still, the concept matters for JavaScript apps because your Node backend may send text to a topic modeling service or LLM workflow.

The BERTopic paper describes topic modeling as a clustering problem. It creates document embeddings, clusters them, and then uses class-based TF-IDF to create topic representations. This fits real content analysis because a pile of user feedback may contain unknown themes like “shipping delays,” “confusing invoices,” or “mobile app crashes” before your team has official labels for them.

A good workflow can look like this:

  1. Collect text documents.
  2. Clean and deduplicate them.
  3. Create embeddings.
  4. Cluster similar texts.
  5. Extract keywords for each cluster.
  6. Review clusters manually.
  7. Turn useful clusters into official topic labels.
  8. Use JavaScript rules or classification to tag future text.

That last step matters. Topic modeling is great for discovery. Once you know the topics, a simpler classifier may handle day-to-day tagging.

Step 11: Build a full topic identifier

Now let’s combine the practical pieces:

  1. Use weighted rules for clear matches.
  2. Return multiple topics when needed.
  3. Send unclear text to review or a model.
function identifyTopics(text, topics, options = {}) {
  const threshold = options.threshold ?? 0.6;
  const minMultiScore = options.minMultiScore ?? 2;

  const singleTopic = identifyWeightedTopic(text, topics, threshold);
  const multipleTopics = identifyMultipleTopics(text, topics, minMultiScore);

  return {
    text,
    mainTopic: singleTopic.topic,
    confidence: singleTopic.confidence,
    action: singleTopic.action,
    topics: multipleTopics.topics,
    hasMultipleTopics: multipleTopics.hasMultipleTopics,
    scores: singleTopic.scores
  };
}

Test:

const result = identifyTopics(
  "I can't log in and I was charged twice for my subscription.",
  weightedTopics
);

console.log(result);

Example output:

{
  text: "I can't log in and I was charged twice for my subscription.",
  mainTopic: "billing",
  confidence: 0.6,
  action: "auto_tag",
  topics: [
    { topic: "billing", score: 6 },
    { topic: "account", score: 3 }
  ],
  hasMultipleTopics: true,
  scores: {
    billing: 6,
    account: 3,
    bug: 0,
    feature_request: 0
  }
}

This is the kind of output your app can actually use.

Step 12: Add a review path for low confidence

Please add a review path. Future-you will be grateful.

Topic detection will always have unclear cases.

Example:

The dashboard is weird and I don’t understand what happened to my plan.

Is this a bug? Billing? UX feedback? Account issue? Maybe several.

So when confidence is low, return review.

function routeTopicResult(result) {
  if (result.action === "review") {
    return {
      queue: "manual_review",
      reason: "Low confidence topic match"
    };
  }

  if (result.hasMultipleTopics) {
    return {
      queue: "multi_topic_review",
      reason: "Message matched more than one topic"
    };
  }

  return {
    queue: result.mainTopic,
    reason: "Confident topic match"
  };
}

Use it:

const topicResult = identifyTopics(
  "The dashboard is weird and I don't understand my plan.",
  weightedTopics
);

console.log(routeTopicResult(topicResult));

This keeps your automation safer.

How should you store topic results?

If this goes into a real app, store more than the final topic.

Store:

FieldWhy it helps
Original textLets reviewers check the result
Main topicUsed for routing
All matched topicsUseful for multi-topic content
ConfidenceHelps review decisions
ScoresExplains why the label was chosen
Keywords matchedHelps debugging
Method usedRules, TF-IDF, zero-shot, LLM
TimestampUseful for analytics
Reviewer correctionHelps improve the system later

For example:

{
  "text": "I was charged twice for my subscription.",
  "main_topic": "billing",
  "confidence": 1,
  "matched_keywords": ["charged", "subscription"],
  "method": "weighted_rules",
  "review_required": false
}

This is much easier to debug than just storing:

{

  “topic”: “billing”

}

How do you improve topic accuracy over time?

The best topic systems get better from real feedback.

Use this loop:

  1. Start with simple rules.
  2. Store topic results and confidence.
  3. Review low-confidence texts.
  4. Save reviewer corrections.
  5. Add missing keywords and phrases.
  6. Split topics that are too broad.
  7. Merge topics that overlap too much.
  8. Test rules on old examples before deploying changes.
  9. Add zero-shot or LLM classification when rules become too messy.
  10. Keep measuring accuracy and review rate.

This is boring in the best way. It makes the system practical.

Track these metrics:

MetricWhat it tells you
AccuracyHow often the topic is correct
Review rateHow much manual work remains
Unknown rateHow often the system cannot classify text
Multi-topic rateHow often messages need more than one label
False routing rateHow often text goes to the wrong team
Top confused topicsWhich labels overlap too much
Time savedWhether the feature is worth it

If your unknown rate is high, add better keywords or model-based classification.

If your false routing rate is high, raise the confidence threshold.

If two topics are constantly confused, rewrite the labels or merge them.

When should you use LLMAPI?

LLMAPI fits when topic identification becomes part of a larger AI workflow.

For example, your app may need to:

  1. Detect the topic.
  2. Extract entities.
  3. Detect sentiment.
  4. Summarize the text.
  5. Route it to a team.
  6. Generate a reply.
  7. Log the result for analytics.

You can use JavaScript rules for easy topic matches, then call LLMAPI for messy cases.

Example setup:

  1. JavaScript rules check the text first.
  2. If confidence is high, the app auto-tags the text.
  3. If confidence is low, the app sends the text to LLMAPI.
  4. LLMAPI routes the task to a model that handles classification well.
  5. The model returns structured JSON.
  6. Your app validates the JSON and stores the topic.

Example prompt:

Classify this message into one or more topics:
billing, account, bug, feature_request, unknown.

Return valid JSON only:
{
  "topics": [],
  "confidence": 0,
  "reason": ""
}

Message:
"I don't understand why my card was charged again."

Expected output:

{
  "topics": ["billing"],
  "confidence": 0.92,
  "reason": "The message mentions a card charge."
}

This hybrid setup is usually practical because it keeps easy cases cheap and sends hard cases to a stronger model.

What are the common mistakes?

MistakeBetter approach
Starting with too many topicsStart with 4 to 8 clear topics
Using only exact wordsAdd phrases, synonyms, and weights
Ignoring multi-topic messagesReturn several topics when needed
No confidence scoreAdd review logic
No unknown categoryAlways allow “unknown”
No real test dataTest with real messages
No reviewer feedbackSave corrections and improve
Treating keywords as perfectUse rules as signals
Sending every text to an LLMUse rules first when possible
Never updating labelsLet topics evolve with real data

The biggest thing: topic labels should match what your app does next. If a topic does not change routing, reporting, automation, or user experience, maybe it does not need to exist yet.

Full copy-paste example

Here is a complete starter version.

const topics = {
  billing: {
    refund: 4,
    charged: 4,
    payment: 3,
    invoice: 3,
    subscription: 2,
    receipt: 2,
    price: 1,
    pricing: 1
  },
  account: {
    login: 3,
    password: 4,
    account: 2,
    "sign in": 3,
    locked: 4,
    reset: 3,
    email: 1
  },
  bug: {
    bug: 3,
    crash: 5,
    broken: 4,
    error: 3,
    "not working": 4,
    glitch: 3,
    freeze: 4
  },
  feature_request: {
    feature: 2,
    add: 2,
    request: 2,
    suggestion: 2,
    "would be nice": 4,
    "can you build": 5
  }
};

function normalizeText(text) {
  return text
    .toLowerCase()
    .replace(/[^\w\s]/g, " ")
    .replace(/\s+/g, " ")
    .trim();
}

function scoreWeightedTopics(text, topics) {
  const normalizedText = normalizeText(text);
  const scores = {};
  const matchedKeywords = {};

  for (const [topic, keywords] of Object.entries(topics)) {
    scores[topic] = 0;
    matchedKeywords[topic] = [];

    for (const [keyword, weight] of Object.entries(keywords)) {
      if (normalizedText.includes(keyword.toLowerCase())) {
        scores[topic] += weight;
        matchedKeywords[topic].push(keyword);
      }
    }
  }

  return { scores, matchedKeywords };
}

function identifyWeightedTopic(text, topics, threshold = 0.6) {
  const { scores, matchedKeywords } = scoreWeightedTopics(text, topics);

  const sortedTopics = Object.entries(scores).sort(
    (a, b) => b[1] - a[1]
  );

  const [bestTopic, bestScore] = sortedTopics[0];

  if (bestScore === 0) {
    return {
      mainTopic: "unknown",
      confidence: 0,
      action: "review",
      scores,
      matchedKeywords
    };
  }

  const totalScore = Object.values(scores).reduce(
    (sum, score) => sum + score,
    0
  );

  const confidence = Number((bestScore / totalScore).toFixed(2));

  const matchedTopics = sortedTopics
    .filter(([, score]) => score > 0)
    .map(([topic, score]) => ({
      topic,
      score,
      keywords: matchedKeywords[topic]
    }));

  return {
    mainTopic: bestTopic,
    confidence,
    action: confidence >= threshold ? "auto_tag" : "review",
    topics: matchedTopics,
    hasMultipleTopics: matchedTopics.length > 1,
    scores,
    matchedKeywords
  };
}

function routeTopicResult(result) {
  if (result.action === "review") {
    return {
      queue: "manual_review",
      reason: "Low confidence topic match"
    };
  }

  if (result.hasMultipleTopics) {
    return {
      queue: "multi_topic_review",
      reason: "Message matched more than one topic"
    };
  }

  return {
    queue: result.mainTopic,
    reason: "Confident topic match"
  };
}

const message = "I can't log in and I was charged twice for my subscription.";

const result = identifyWeightedTopic(message, topics);

console.log(result);
console.log(routeTopicResult(result));

Final thoughts

You can identify topics in text content with JavaScript in a few different ways.

Start with weighted keyword rules if you already know your topics. Add confidence scores so your app knows when to auto-tag and when to ask for review. Return multiple topics when the message covers more than one issue. Use TF-IDF when you have many documents and want better keyword signals. Try zero-shot classification when your labels change often or users describe things in messy ways.

For production, keep the workflow simple: classify easy texts with rules, send unclear texts to a model, validate the output, and save reviewer feedback.

And if topic detection is part of a bigger AI workflow, use LLMAPI to route the harder classification, summarization, and follow-up tasks across different models without locking your app into one provider.

Deploy in minutes