LLM Guides

How to Build Custom Text Categories with JavaScript

Jul 08, 2026

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:

TextCategory
“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:

CategoryExample
support“I need help with my account.”
sales“Can I book a demo?”
feedback“The new dashboard is confusing.”

Or very specific:

CategoryExample
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 fitExample
Support routingBilling, account, bug, feature
Basic moderationSpam, abuse, adult content, scam
Feedback sortingPricing, UX, performance, docs
Lead routingSales, support, partnership
Simple analyticsProduct 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:

OptionBest for
NaturalNode.js NLP, tokenization, TF-IDF, simple classifiers
winkNLPFast JavaScript NLP pipelines
TensorFlow.jsBrowser or Node machine learning
LLM APIFlexible classification with natural language labels
LLMAPIRouting 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:

TaskSuggested route
Simple keyword categoryJavaScript rules
Messy text classificationLLM through LLMAPI
High-volume taggingCheaper model
High-risk support ticketStronger model
Fallback if provider failsBackup model via LLMAPI
Analytics summariesSeparate 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:

RuleExample
Keep labels specificrefund_request beats money_stuff
Avoid overlapbilling and pricing should mean different things
Add examplesWrite 5-10 sample messages per category
Add fallbackAlways have uncategorized
Allow reviewMixed messages need human checks
Track changesCategories 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:

MetricWhy it helps
AccuracyHow often the top category is right
Review rateHow much text needs human review
Uncategorized rateShows missing categories or keywords
Confusion pairsShows overlapping categories
False routingShows risky mistakes

If billing and account_issue get mixed often, your categories may need clearer definitions.

Common mistakes

MistakeBetter approach
Too many categories at the startBegin with 4-8 categories
No fallback categoryAdd uncategorized
No confidence thresholdUse review when unsure
Only exact keywordsAdd phrases and weights
No real test casesBuild a small test set
Overlapping labelsDefine category meanings clearly
No multi-label supportReturn top categories when useful
Sending everything to an LLMUse 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.

Deploy in minutes