Text categories are useful when your app needs to sort messages, comments, tickets, reviews, emails, or documents into clear groups.
For example, a support tool may need categories like:
| Text | Category |
| “I can’t log into my account.” | account_issue |
| “Can I get a refund?” | billing |
| “The app keeps crashing.” | bug_report |
| “Do you have a cheaper plan?” | pricing_question |
You can build this in JavaScript in a few ways. The simplest version uses keywords. A better version uses scoring. A more advanced version uses a machine learning classifier or an LLM.
In this guide, we’ll start with a clean keyword-based classifier, improve it with scoring, and then show when to move to ML or an API-based workflow.
Why we can write this
We’ve spent around 6 years working with AI APIs, NLP tools, text workflows, and developer-focused automation. We also researched current JavaScript NLP tools, browser APIs, and text classification options for this article.
For simple category matching, JavaScript’s built-in tools are enough. For more advanced text processing, libraries like Natural can help with tokenizing, stemming, classification, phonetics, TF-IDF, and other NLP tasks. If you want machine learning in JavaScript, TensorFlow.js also supports NLP and text-related model workflows.
What are custom text categories?
Custom text categories are labels you define for your own app.
They can be broad:
| Category | Example |
| support | “I need help with my account.” |
| sales | “Can I book a demo?” |
| feedback | “The new dashboard is confusing.” |
Or very specific:
| Category | Example |
| refund_request | “Please return my money.” |
| password_reset | “I forgot my password.” |
| feature_request | “Can you add dark mode?” |
| shipping_delay | “My package is late.” |
The right category system depends on what your app does next.
If you only need routing, broad categories are fine. If you need analytics or automation, use more specific labels.
Step 1: Define your categories
Start with categories that match real user behavior.
const categories = {
billing: [
"refund",
"invoice",
"payment",
"charged",
"subscription",
"billing",
"receipt"
],
account_issue: [
"login",
"password",
"account",
"sign in",
"locked",
"reset"
],
bug_report: [
"bug",
"crash",
"broken",
"error",
"not working",
"glitch"
],
feature_request: [
"feature",
"add",
"can you build",
"request",
"would be useful"
]
};
Keep the first version small. Four to eight categories are easier to test than twenty.
Step 2: Normalize the text
Before matching categories, clean the text.
function normalizeText(text) {
return text
.toLowerCase()
.replace(/[^\w\s]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
Test it:
console.log(normalizeText(“Hi!! I can’t LOGIN to my account.”));
Output:
hi i can t login to my account
This makes matching more predictable.
Step 3: Build a simple classifier
Now we can check if the text contains category keywords.
function categorizeText(text, categories) {
const normalizedText = normalizeText(text);
for (const [category, keywords] of Object.entries(categories)) {
const hasMatch = keywords.some((keyword) =>
normalizedText.includes(keyword.toLowerCase())
);
if (hasMatch) {
return category;
}
}
return "uncategorized";
}
Try it:
const message = "I was charged twice for my subscription.";
console.log(categorizeText(message, categories));
Output:
billing
This works for a quick prototype, but it has one obvious issue: it returns the first matching category. If a message mentions both “login” and “payment,” we need a better way to choose.
Step 4: Add category scoring
A scoring system is more useful. Each keyword match adds points to a category, and the highest score wins.
function scoreCategories(text, categories) {
const normalizedText = normalizeText(text);
const scores = {};
for (const [category, keywords] of Object.entries(categories)) {
scores[category] = 0;
for (const keyword of keywords) {
const normalizedKeyword = keyword.toLowerCase();
if (normalizedText.includes(normalizedKeyword)) {
scores[category] += 1;
}
}
}
return scores;
}
Now choose the best category:
function categorizeText(text, categories) {
const scores = scoreCategories(text, categories);
const [bestCategory, bestScore] = Object.entries(scores).sort(
(a, b) => b[1] - a[1]
)[0];
if (bestScore === 0) {
return {
category: "uncategorized",
confidence: 0,
scores
};
}
const totalScore = Object.values(scores).reduce((sum, score) => sum + score, 0);
return {
category: bestCategory,
confidence: Number((bestScore / totalScore).toFixed(2)),
scores
};
}
Test it:
const message = "I can't login and I need to reset my password.";
console.log(categorizeText(message, categories));
Output:
{
category: "account_issue",
confidence: 1,
scores: {
billing: 0,
account_issue: 3,
bug_report: 0,
feature_request: 0
}
}
That is already better. You get the category, confidence, and all scores.
Step 5: Add weighted keywords
Some words are stronger than others.
For example, “refund” is a stronger billing signal than “payment.” “Crash” is a stronger bug signal than “not working.”
Use weighted keywords:
const weightedCategories = {
billing: {
refund: 3,
invoice: 2,
payment: 2,
charged: 3,
subscription: 1,
receipt: 2
},
account_issue: {
login: 2,
password: 3,
account: 1,
"sign in": 2,
locked: 3,
reset: 2
},
bug_report: {
bug: 3,
crash: 4,
broken: 3,
error: 2,
"not working": 3,
glitch: 2
},
feature_request: {
feature: 2,
add: 1,
"can you build": 4,
request: 2,
"would be useful": 3
}
};
Update the scoring function:
function scoreWeightedCategories(text, categories) {
const normalizedText = normalizeText(text);
const scores = {};
for (const [category, keywords] of Object.entries(categories)) {
scores[category] = 0;
for (const [keyword, weight] of Object.entries(keywords)) {
if (normalizedText.includes(keyword.toLowerCase())) {
scores[category] += weight;
}
}
}
return scores;
}
And categorize:
function categorizeWeightedText(text, categories) {
const scores = scoreWeightedCategories(text, categories);
const [bestCategory, bestScore] = Object.entries(scores).sort(
(a, b) => b[1] - a[1]
)[0];
if (bestScore === 0) {
return {
category: "uncategorized",
confidence: 0,
scores
};
}
const totalScore = Object.values(scores).reduce((sum, score) => sum + score, 0);
return {
category: bestCategory,
confidence: Number((bestScore / totalScore).toFixed(2)),
scores
};
}
Test it:
const message = “The app keeps crashing when I try to open invoices.”;
console.log(categorizeWeightedText(message, weightedCategories));
Output:
{
category: "bug_report",
confidence: 0.67,
scores: {
billing: 2,
account_issue: 0,
bug_report: 4,
feature_request: 0
}
}
This is closer to how real classification works. The message mentions invoices, but “crashing” is the stronger signal.
Step 6: Add a minimum confidence rule
You do not always want to trust the category.
If the score is weak or mixed, send the text to review.
function classifyWithThreshold(text, categories, threshold = 0.6) {
const result = categorizeWeightedText(text, categories);
if (result.confidence < threshold) {
return {
...result,
action: "needs_review"
};
}
return {
...result,
action: "auto_categorize"
};
}
Test:
console.log(
classifyWithThreshold(
"I have a question about my account and subscription.",
weightedCategories
)
);
Example output:
{
category: "billing",
confidence: 0.5,
scores: {
billing: 1,
account_issue: 1,
bug_report: 0,
feature_request: 0
},
action: "needs_review"
}
This is important. A good category system should know when it is unsure.
Step 7: Return multiple categories
Sometimes one message belongs to more than one category.
Example:
I can’t log in, and I was also charged twice.
That should probably be both account_issue and billing.
function getTopCategories(text, categories, minScore = 1) {
const scores = scoreWeightedCategories(text, categories);
return Object.entries(scores)
.filter(([, score]) => score >= minScore)
.sort((a, b) => b[1] - a[1])
.map(([category, score]) => ({ category, score }));
}
Test:
console.log(
getTopCategories(
"I can't log in, and I was charged twice.",
weightedCategories
)
);
Output:
[
{ category: "billing", score: 3 },
{ category: "account_issue", score: 2 }
]
Multi-label categories are useful for support tools, content moderation, feedback analysis, and customer research.
Full copy-paste example
Here is the full working version.
const categories = {
billing: {
refund: 3,
invoice: 2,
payment: 2,
charged: 3,
subscription: 1,
receipt: 2
},
account_issue: {
login: 2,
password: 3,
account: 1,
"sign in": 2,
locked: 3,
reset: 2
},
bug_report: {
bug: 3,
crash: 4,
broken: 3,
error: 2,
"not working": 3,
glitch: 2
},
feature_request: {
feature: 2,
add: 1,
"can you build": 4,
request: 2,
"would be useful": 3
}
};
function normalizeText(text) {
return text
.toLowerCase()
.replace(/[^\w\s]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function scoreCategories(text, categories) {
const normalizedText = normalizeText(text);
const scores = {};
for (const [category, keywords] of Object.entries(categories)) {
scores[category] = 0;
for (const [keyword, weight] of Object.entries(keywords)) {
if (normalizedText.includes(keyword.toLowerCase())) {
scores[category] += weight;
}
}
}
return scores;
}
function categorizeText(text, categories, threshold = 0.6) {
const scores = scoreCategories(text, categories);
const [bestCategory, bestScore] = Object.entries(scores).sort(
(a, b) => b[1] - a[1]
)[0];
if (bestScore === 0) {
return {
category: "uncategorized",
confidence: 0,
action: "needs_review",
scores
};
}
const totalScore = Object.values(scores).reduce((sum, score) => sum + score, 0);
const confidence = Number((bestScore / totalScore).toFixed(2));
return {
category: bestCategory,
confidence,
action: confidence >= threshold ? "auto_categorize" : "needs_review",
scores
};
}
function getTopCategories(text, categories, minScore = 1) {
const scores = scoreCategories(text, categories);
return Object.entries(scores)
.filter(([, score]) => score >= minScore)
.sort((a, b) => b[1] - a[1])
.map(([category, score]) => ({ category, score }));
}
const message = "The app keeps crashing when I try to open my invoice.";
console.log(categorizeText(message, categories));
console.log(getTopCategories(message, categories));
When keyword categories are enough
Keyword-based categories are useful when the categories are clear.
| Good fit | Example |
| Support routing | Billing, account, bug, feature |
| Basic moderation | Spam, abuse, adult content, scam |
| Feedback sorting | Pricing, UX, performance, docs |
| Lead routing | Sales, support, partnership |
| Simple analytics | Product mentions, complaints, requests |
This approach is fast, cheap, easy to explain, and simple to debug.
If a message was categorized as billing, you can see exactly which word caused it.
When to use NLP or machine learning
Keyword rules become painful when users phrase the same idea in many different ways.
For example:
I want my money back.
That is a refund request, even if the word “refund” never appears.
This is where NLP or machine learning helps.
You can use:
| Option | Best for |
| Natural | Node.js NLP, tokenization, TF-IDF, simple classifiers |
| winkNLP | Fast JavaScript NLP pipelines |
| TensorFlow.js | Browser or Node machine learning |
| LLM API | Flexible classification with natural language labels |
| LLMAPI | Routing classification across different models/providers |
The Natural library is useful if you want classic NLP tools directly in Node.js. TensorFlow.js is better if you want browser-friendly or Node-based machine learning models.
Example: Classify with an LLM
If rules are too limited, use an LLM to classify text into your custom categories.
Example prompt:
Classify this customer message into one of these categories:
billing, account_issue, bug_report, feature_request, uncategorized.
Message:
"I want my money back because the app crashed all week."
Return JSON only:
{
"category": "...",
"confidence": 0.0,
"reason": "..."
}
Expected output:
{
"category": "billing",
"confidence": 0.74,
"reason": "The user asks for money back, which is a refund-related billing issue."
}
This works well when language is messy, indirect, or hard to capture with keywords.
For production, keep the allowed categories strict. Also validate the JSON before using it.
Where LLMAPI fits
Custom categories often become part of a bigger text workflow.
For example:
User message → Detect language → Anonymize personal data → Classify category → Check urgency → Route to the right team → Generate draft response → Store analytics
LLMAPI can help when you want to classify text with different models, compare cost, add fallback, or route easy tasks to cheaper models.
A practical setup:
| Task | Suggested route |
| Simple keyword category | JavaScript rules |
| Messy text classification | LLM through LLMAPI |
| High-volume tagging | Cheaper model |
| High-risk support ticket | Stronger model |
| Fallback if provider fails | Backup model via LLMAPI |
| Analytics summaries | Separate summarization model |
This gives you more control than sending every text to the same model.
How to choose categories
Bad categories make classification messy.
Use these rules:
| Rule | Example |
| Keep labels specific | refund_request beats money_stuff |
| Avoid overlap | billing and pricing should mean different things |
| Add examples | Write 5-10 sample messages per category |
| Add fallback | Always have uncategorized |
| Allow review | Mixed messages need human checks |
| Track changes | Categories should evolve with real data |
Start with real messages if you have them. Read 50 to 100 examples and group them by what action the app should take next.
That last part matters: categories should support a workflow.
Testing your categories
Create a small test set:
const testCases = [
{
text: "I forgot my password and can't log in.",
expected: "account_issue"
},
{
text: "Please refund my last payment.",
expected: "billing"
},
{
text: "The app crashes every time I open it.",
expected: "bug_report"
},
{
text: "Can you add dark mode?",
expected: "feature_request"
}
];
for (const test of testCases) {
const result = categorizeText(test.text, categories);
console.log({
text: test.text,
expected: test.expected,
actual: result.category,
passed: result.category === test.expected
});
}
Use the failed cases to improve your keywords and weights.
Track:
| Metric | Why it helps |
| Accuracy | How often the top category is right |
| Review rate | How much text needs human review |
| Uncategorized rate | Shows missing categories or keywords |
| Confusion pairs | Shows overlapping categories |
| False routing | Shows risky mistakes |
If billing and account_issue get mixed often, your categories may need clearer definitions.
Common mistakes
| Mistake | Better approach |
| Too many categories at the start | Begin with 4-8 categories |
| No fallback category | Add uncategorized |
| No confidence threshold | Use review when unsure |
| Only exact keywords | Add phrases and weights |
| No real test cases | Build a small test set |
| Overlapping labels | Define category meanings clearly |
| No multi-label support | Return top categories when useful |
| Sending everything to an LLM | Use rules for easy cases |
Simple rules can handle a lot. Use ML or LLMs when rules stop being practical.
Final thoughts
You can build custom text categories in JavaScript with a few simple functions: normalize text, match keywords, score categories, and add a confidence threshold.
Start with rules because they are fast, cheap, and easy to debug. Add weighted keywords when categories overlap. Add multi-label output when messages can belong to more than one group. Move to NLP or LLM-based classification when users phrase things in too many different ways for keywords to keep up.
If classification becomes part of a larger AI workflow, use LLMAPI to route tasks across models, control costs, and add fallback without rebuilding your app around one provider.