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:
- Clean and normalize text.
- Extract useful keywords.
- Score topics with rules.
- Return one or multiple topic labels.
- Use TF-IDF when you have many documents.
- Use zero-shot classification when you want flexible labels.
- 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:
| Method | Best for |
| Keyword rules | Simple routing and clear categories |
| Weighted scoring | Messages with overlapping topics |
| TF-IDF | Finding important words across many documents |
| Topic modeling | Discovering hidden themes in large datasets |
| Zero-shot classification | Labeling text with custom topics without training |
| LLM-based classification | Flexible, 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 situation | Start with |
| You have 5-20 known topics | Keyword scoring |
| You need to route support tickets | Weighted rules + confidence |
| You have many articles or reviews | TF-IDF |
| You want to discover topics automatically | Topic modeling or embeddings |
| Your labels change all the time | Zero-shot classification |
| Text is messy and indirect | LLM-based classification |
| You need a production workflow | Rules + 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:
- Collect text documents.
- Clean and deduplicate them.
- Create embeddings.
- Cluster similar texts.
- Extract keywords for each cluster.
- Review clusters manually.
- Turn useful clusters into official topic labels.
- 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:
- Use weighted rules for clear matches.
- Return multiple topics when needed.
- 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:
| Field | Why it helps |
| Original text | Lets reviewers check the result |
| Main topic | Used for routing |
| All matched topics | Useful for multi-topic content |
| Confidence | Helps review decisions |
| Scores | Explains why the label was chosen |
| Keywords matched | Helps debugging |
| Method used | Rules, TF-IDF, zero-shot, LLM |
| Timestamp | Useful for analytics |
| Reviewer correction | Helps 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:
- Start with simple rules.
- Store topic results and confidence.
- Review low-confidence texts.
- Save reviewer corrections.
- Add missing keywords and phrases.
- Split topics that are too broad.
- Merge topics that overlap too much.
- Test rules on old examples before deploying changes.
- Add zero-shot or LLM classification when rules become too messy.
- Keep measuring accuracy and review rate.
This is boring in the best way. It makes the system practical.
Track these metrics:
| Metric | What it tells you |
| Accuracy | How often the topic is correct |
| Review rate | How much manual work remains |
| Unknown rate | How often the system cannot classify text |
| Multi-topic rate | How often messages need more than one label |
| False routing rate | How often text goes to the wrong team |
| Top confused topics | Which labels overlap too much |
| Time saved | Whether 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:
- Detect the topic.
- Extract entities.
- Detect sentiment.
- Summarize the text.
- Route it to a team.
- Generate a reply.
- Log the result for analytics.
You can use JavaScript rules for easy topic matches, then call LLMAPI for messy cases.
Example setup:
- JavaScript rules check the text first.
- If confidence is high, the app auto-tags the text.
- If confidence is low, the app sends the text to LLMAPI.
- LLMAPI routes the task to a model that handles classification well.
- The model returns structured JSON.
- 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?
| Mistake | Better approach |
| Starting with too many topics | Start with 4 to 8 clear topics |
| Using only exact words | Add phrases, synonyms, and weights |
| Ignoring multi-topic messages | Return several topics when needed |
| No confidence score | Add review logic |
| No unknown category | Always allow “unknown” |
| No real test data | Test with real messages |
| No reviewer feedback | Save corrections and improve |
| Treating keywords as perfect | Use rules as signals |
| Sending every text to an LLM | Use rules first when possible |
| Never updating labels | Let 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.