npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

teem-seo

v0.6.0

Published

Yoast-like SEO and readability analysis for React and Next.js — English & Persian

Downloads

69

Readme

TeemSEO

Yoast-like SEO and readability analysis for React and Next.js, with full English and Persian (فارسی) support.

TeemSEO sidebar — focus keyphrase, meta fields, and Google SERP preview

TeemSEO analyzes content against a focus keyphrase, meta fields, structure, links, and readability — then shows traffic-light feedback (good / ok / bad). Use the full UI sidebar, headless analysis only, or compose your own UI from exported hooks and components.

Table of contents

Features

  • SEO checks: keyphrase placement, density, title/meta length, text length, H1/subheadings, internal & outbound links, optional duplicate keyphrase
  • Readability: English Flesch + shared rules; Persian-specific heuristics (no English Flesch on FA content)
  • Bilingual UI messages (EN / FA) with automatic RTL for Persian
  • SERP snippet preview
  • Internal link suggestions accordion (host provides ranking via callback or API)
  • Persian slug intelligence: سئوseo, محتواmohtava, percent-encoded Persian slugs, and more
  • Tree-shakeable core — use without React if you only need analyze()

Requirements

  • Node.js ≥ 18
  • React ≥ 18 (optional — only for UI)
  • Next.js ≥ 13 (optional — only for teem-seo/next helpers)

Peer dependencies are optional in package.json; install react / react-dom when using the UI, and next when using metadata/JSON-LD helpers.

Install

npm install teem-seo
# or
pnpm add teem-seo
# or
yarn add teem-seo

Import styles once wherever you render <TeemSEO />:

import 'teem-seo/styles.css'

Package entry points

| Import | When to use | |--------|-------------| | teem-seo | Default — core analysis + React UI re-exported | | teem-seo/react | Recommended in Next.js App Router — client components only ('use client') | | teem-seo/next | buildMetadata, buildJsonLd for Next.js metadata / structured data | | teem-seo/styles.css | Default sidebar styles |

// Vite / CRA / Pages Router — either works
import { TeemSEO, analyzeSync } from 'teem-seo'

// Next.js App Router — prefer the react entry in Client Components
import { TeemSEO } from 'teem-seo/react'
import { buildMetadata } from 'teem-seo/next'

Quick start (React)

import { TeemSEO } from 'teem-seo'
import 'teem-seo/styles.css'

export function EditorSidebar({ html }: { html: string }) {
  return (
    <TeemSEO
      content={html}
      focusKeyphrase="content SEO"
      title="Complete guide to content SEO"
      metaDescription="Practical tactics for titles, metas, and readable copy."
      slug="content-seo-guide"
      siteUrl="https://example.com"
      locale="auto"
      onChangeMeta={(meta) => console.log(meta)}
      onAnalysis={(result) => console.log(result.overallScore)}
    />
  )
}

Content format: pass HTML (from TipTap, CKEditor, Quill, etc.) or plain text. Plain text is wrapped into paragraphs automatically.

Controlled meta: pass initial focusKeyphrase, title, metaDescription, slug as props and listen to onChangeMeta to sync with your CMS state (see Integration patterns).

Next.js (App Router)

Split server metadata from client analysis:

// app/blog/[slug]/edit/page.tsx
'use client'

import { TeemSEO } from 'teem-seo/react'
import 'teem-seo/styles.css'

export default function EditPage({ html }: { html: string }) {
  return (
    <TeemSEO
      content={html}
      siteUrl="https://example.com"
      locale="auto"
      // ...meta props
    />
  )
}
// app/blog/[slug]/page.tsx
import { buildMetadata, buildJsonLd, jsonLdScriptContent } from 'teem-seo/next'

export async function generateMetadata({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug)
  return buildMetadata({
    title: post.title,
    description: post.metaDescription,
    slug: post.slug,
    siteUrl: 'https://example.com',
    locale: 'fa',
    from: post, // can include allowIndex / allowFollow / canonicalUrl / robots flags
  })
}

export default async function Page({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug)
  const jsonLd = buildJsonLd({
    type: 'Article',
    title: post.title,
    description: post.metaDescription,
    url: `https://example.com/${post.slug}`,
    authorName: post.author,
    locale: 'fa',
  })

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      {/* or: dangerouslySetInnerHTML={{ __html: jsonLdScriptContent({ ... }) }} */}
      <article>{/* ... */}</article>
    </>
  )
}

Headless analysis

No UI — use in APIs, workers, or custom dashboards:

import { analyze, analyzeSync } from 'teem-seo'

const result = analyzeSync({
  content: '<h1>سلام</h1><p>این متن درباره سئو محتوا است...</p>',
  focusKeyphrase: 'سئو محتوا',
  title: 'راهنمای سئو محتوا',
  metaDescription: 'همه چیز درباره سئو محتوا برای نویسندگان فارسی‌زبان.',
  slug: 'seo-mohtava',
  siteUrl: 'https://example.com',
  locale: 'auto',
})

console.log(result.seoScore, result.readabilityScore, result.overallScore)
console.log(result.seo, result.readability, result.stats)

Async variant when checking duplicate keyphrases across your site:

const result = await analyze({
  content,
  focusKeyphrase: 'seo',
  siteUrl: 'https://example.com',
  isKeyphraseUsedElsewhere: async (kp) => db.exists(kp),
})

analyzeSync does not call isKeyphraseUsedElsewhere (use analyze for that).

Analysis result shape

interface AnalysisResult {
  locale: 'en' | 'fa'              // detected content language
  messageLocale: 'en' | 'fa'       // language of assessment messages
  seo: AssessmentResult[]
  readability: AssessmentResult[]
  seoScore: 'good' | 'ok' | 'bad'
  readabilityScore: 'good' | 'ok' | 'bad'
  overallScore: 'good' | 'ok' | 'bad'
  stats: {
    wordCount: number
    sentenceCount: number
    paragraphCount: number
    headingCount: number
    imageCount: number
    linkCount: number
    keyphraseDensity: number       // percentage
  }
}

interface AssessmentResult {
  id: AssessmentId
  rating: 'good' | 'ok' | 'bad'
  score: number
  text: string                     // localized message
  meta?: Record<string, string | number | boolean>
}

Scores aggregate individual assessments: mostly goodgood overall; mix of ok/badok; mostly badbad.

Suggested internal links

TeemSEO does not crawl your site. Pass getInternalLinkSuggestions so your CMS/API ranks related pages for the accordion UI.

Query TeemSEO sends:

interface InternalLinkQuery {
  focusKeyphrase: string
  title: string
  slug: string
  prominentWords: string[]   // top content words (stop words removed)
  locale: 'en' | 'fa'
  excludeUrls: string[]      // current page URL + internal links already in content
  limit: number
}

Each suggestion you return:

interface InternalLinkSuggestion {
  title: string
  url: string
  excerpt?: string
  score?: number             // 0..1, optional sort hint
  matchedTerms?: string[]    // shown as chips in the UI
}

Callback (recommended)

import { TeemSEO, type GetInternalLinkSuggestions } from 'teem-seo'
import 'teem-seo/styles.css'

const getInternalLinkSuggestions: GetInternalLinkSuggestions = async (query) => {
  const res = await fetch('/api/internal-links', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(query),
  })
  const data = await res.json()
  return data.suggestions
}

<TeemSEO
  content={html}
  siteUrl="https://example.com"
  getInternalLinkSuggestions={getInternalLinkSuggestions}
/>

URL helper

If your API accepts POST JSON and responds with { suggestions: [...] }:

import { TeemSEO, createInternalLinkSuggestionsFetcher } from 'teem-seo'

<TeemSEO
  content={html}
  siteUrl="https://example.com"
  getInternalLinkSuggestions={createInternalLinkSuggestionsFetcher(
    'https://example.com/api/internal-links',
  )}
/>

Without this prop, the accordion shows a short “provide getInternalLinkSuggestions” message.

Fetch behavior: requests run only when the accordion is opened. After a successful response for the current content/meta, reopen/close does not refetch. Editing content or keyphrase invalidates the cache.

Link classification (internal vs outbound)

Pass siteUrl (site origin) so TeemSEO can tell internal links from outbound ones:

| href | With siteUrl="https://example.com" | |--------|--------------------------------------| | /blog/seo | Internal (root-relative path) | | https://example.com/blog/seo | Internal (same hostname) | | //example.com/about | Internal (protocol-relative, same host) | | https://other.com/x | Outbound | | google.com | Outbound (bare domain) |

Without siteUrl, absolute / protocol-relative / bare-domain URLs are treated as outbound; root-relative paths (/…) stay internal.

Persian slug matching

The keyphraseInSlug assessment uses smart matching for Persian content:

  • Persian characters in the slug (سئو-محتوا)
  • Percent-encoded Persian slugs
  • Romanized forms (محتواmohtava, سئوseo)
  • Common English equivalents for loanwords
  • Acceptable Latin slugs for Persian keyphrases (e.g. keyphrase سئو محتوا, slug seo-mohtava)

Use the helpers directly if you build your own slug UI:

import { keyphraseMatchesSlug, slugifyKeyphrase, hasArabicScript } from 'teem-seo'

keyphraseMatchesSlug('سئو محتوا', 'seo-mohtava') // true
slugifyKeyphrase('سئو محتوا')                    // 'سئو-محتوا'
hasArabicScript('content SEO')                   // false

What it checks

SEO assessments

| ID | What it checks | |----|----------------| | keyphraseLength | Focus keyphrase is set and not too long | | keyphraseInTitle | Keyphrase appears in SEO title | | keyphraseInMetaDescription | Keyphrase in meta description | | keyphraseInIntroduction | Keyphrase in first paragraph | | keyphraseInContent | Keyphrase in body | | keyphraseDensity | Keyphrase density in range | | keyphraseInSubheadings | Keyphrase in H2–H6 | | keyphraseInImageAlt | Keyphrase in image alt text | | keyphraseInSlug | Keyphrase reflected in slug (incl. Persian rules) | | titleLength | Title length for SERP | | metaDescriptionLength | Meta description length | | textLength | Minimum content length | | internalLinks | At least one internal link | | outboundLinks | At least one outbound link | | singleH1 | Exactly one H1 | | subheadingDistribution | Subheadings spread through long content | | keyphraseUsedElsewhere | Optional duplicate keyphrase on another page |

Readability assessments

| ID | English | Persian | |----|---------|---------| | fleschReadingEase | ✓ | — | | persianReadability | — | ✓ | | sentenceLength | ✓ | ✓ | | paragraphLength | ✓ | ✓ | | passiveVoice | ✓ | ✓ (Persian passive markers) | | transitionWords | ✓ | ✓ | | consecutiveSentences | ✓ | ✓ | | readabilitySubheadings | ✓ | ✓ |

UI messages follow messageLocale (or detected content locale). RTL is applied automatically for FA.

Props reference

<TeemSEO />

| Prop | Type | Default | Description | |------|------|---------|-------------| | content | string | — | Required. HTML or plain text to analyze. | | focusKeyphrase | string | '' | Focus keyphrase for SEO checks. | | title | string | '' | SEO / SERP title. | | metaDescription | string | '' | Meta description. | | slug | string | '' | URL slug (path segment, usually without leading /). | | siteUrl | string | — | Site origin (e.g. https://example.com). Classifies internal vs outbound links and appears in the SERP snippet. | | locale | 'en' \| 'fa' \| 'auto' | 'auto' | Content language; 'auto' detects from text. | | messageLocale | 'en' \| 'fa' | — | Override language of UI / assessment messages (independent of content locale). | | className | string | — | Extra class on the root <aside>. | | analysisOnly | boolean | false | Hide editable meta fields; show analysis + snippet only. | | isCornerstone | boolean | false | Mark page as cornerstone content. | | allowIndex | boolean | true | Allow search engines to index this page. | | allowFollow | boolean | true | Allow search engines to follow links. | | canonicalUrl | string | '' | Explicit canonical URL (Advanced accordion). | | breadcrumbTitle | string | '' | Title used in breadcrumb trails. | | noImageIndex | boolean | false | Meta robots noimageindex. | | noArchive | boolean | false | Meta robots noarchive. | | noSnippet | boolean | false | Meta robots nosnippet. | | onChangeMeta | (value: MetaFieldsValue) => void | — | Fires when editable meta fields change. | | onAnalysis | (result: AnalysisResult) => void | — | Fires whenever a new analysis result is ready. | | isKeyphraseUsedElsewhere | (keyphrase: string) => boolean \| Promise<boolean> | — | Return true if the keyphrase is already used on another page. | | getInternalLinkSuggestions | GetInternalLinkSuggestions | — | Callback that returns related internal pages for the accordion. |

MetaFieldsValue:

{
  focusKeyphrase: string
  title: string
  metaDescription: string
  slug: string
  isCornerstone?: boolean
  allowIndex?: boolean
  allowFollow?: boolean
  canonicalUrl?: string
  breadcrumbTitle?: string
  noImageIndex?: boolean
  noArchive?: boolean
  noSnippet?: boolean
}

analyze / analyzeSync

| Field | Type | Description | |-------|------|-------------| | content | string | Required. HTML or plain text. | | focusKeyphrase | string | Focus keyphrase. | | title | string | SEO title. | | metaDescription | string | Meta description. | | slug | string | URL slug. | | siteUrl | string | Site origin for link classification. | | locale | 'en' \| 'fa' \| 'auto' | Content language. | | messageLocale | 'en' \| 'fa' | Message language override. | | isKeyphraseUsedElsewhere | (keyphrase: string) => boolean \| Promise<boolean> | Async only in analyze(). |

useTeemSEO

Same options as analyze, plus:

| Prop | Type | Default | Description | |------|------|---------|-------------| | debounceMs | number | 200 | Debounce before re-running analysis. |

Returns { result: AnalysisResult | null, loading: boolean }.

useInternalLinkSuggestions

| Prop | Type | Default | Description | |------|------|---------|-------------| | content | string | — | Required. Content used to build the suggestion query. | | focusKeyphrase | string | — | Passed into the query. | | title | string | — | Passed into the query. | | slug | string | — | Current page slug / exclude URL. | | siteUrl | string | — | Absolutizes current URL and existing internal links for excludeUrls. | | locale | 'en' \| 'fa' \| 'auto' | 'auto' | Content language for prominent words. | | messageLocale | 'en' \| 'fa' | — | UI locale hint. | | limit | number | 8 | Max suggestions requested. | | debounceMs | number | — | Debounce before fetching. | | enabled | boolean | — | When false, no request (e.g. accordion closed). | | getInternalLinkSuggestions | GetInternalLinkSuggestions | — | Host fetcher / callback. |

buildMetadata (teem-seo/next)

| Prop | Type | Description | |------|------|-------------| | title | string | Page title. | | description | string | Meta description. | | slug | string | Path segment for canonical URL. | | siteUrl | string | Origin used with slug to build canonical / OG URL. | | canonical | string | Explicit canonical URL (overrides siteUrl + slug). | | locale | 'en' \| 'fa' | Sets Open Graph locale (en_US / fa_IR). | | openGraph | { type?, images? } | Open Graph extras. | | twitter | { card?, site?, creator? } | Twitter card extras. | | from | Partial<MetaFieldsValue> \| AnalysisResult | Prefill title/description/slug/robots/canonical from editor state or analysis. | | robots | { index?, follow?, noimageindex?, noarchive?, nosnippet? } | Robots overrides (defaults from from.allowIndex / from.allowFollow / advanced flags). |

buildJsonLd / jsonLdScriptContent (teem-seo/next)

| Prop | Type | Description | |------|------|-------------| | title | string | Required. Headline / name. | | type | 'Article' \| 'WebPage' \| 'BlogPosting' | Schema @type (default Article). | | description | string | Schema description. | | url | string | Canonical page URL. | | image | string \| string[] | Image URL(s). | | datePublished | string | ISO date published. | | dateModified | string | ISO date modified. | | authorName | string | Author name. | | publisherName | string | Publisher name. | | publisherLogo | string | Publisher logo URL. | | locale | 'en' \| 'fa' | Used to derive inLanguage when not set. | | inLanguage | string | Explicit language tag (e.g. fa-IR). |

jsonLdScriptContent(input) returns a JSON string ready for <script type="application/ld+json">.

Core utilities

Exported from teem-seo for custom pipelines:

| Function | Purpose | |----------|---------| | parseContent(html, { siteUrl?, locale? }) | Parse HTML/plain text into words, headings, links, images, etc. | | detectLocale(text) | Detect 'en' or 'fa' from text | | resolveLocale(option, text) | Resolve 'auto' to a concrete locale | | getLanguagePack(locale) | Stop words, transition words, message templates | | normalizeKeyphrase(kp) | Normalize keyphrase for matching | | slugifyKeyphrase(kp) | Slugify keyphrase (Unicode-aware) | | keyphraseMatchesSlug(kp, slug) | Persian-aware slug ↔ keyphrase match | | hasArabicScript(text) | Whether text contains Arabic/Persian script | | getProminentWords(content, locale?) | Top content words for internal link ranking | | buildInternalLinkQuery(input) | Build InternalLinkQuery object | | createInternalLinkSuggestionsFetcher(url) | POST fetcher helper | | filterExcludedSuggestions(list, excludeUrls) | Remove excluded URLs from suggestions | | aggregateRating(assessments) | Collapse assessments to good/ok/bad | | overallFrom(seo, readability) | Combined overall score | | fleschReadingEase(text) | English Flesch score (0–100+) |

Custom UI (teem-seo/react)

Build your own layout with the same pieces TeemSEO uses internally:

'use client'

import {
  useTeemSEO,
  AnalysisPanel,
  SnippetPreview,
  MetaFields,
  SeoAccordions,
  ScoreBadge,
} from 'teem-seo/react'
import 'teem-seo/styles.css'

export function MySeoPanel({ content, meta, onChangeMeta }) {
  const { result, loading } = useTeemSEO({
    content,
    ...meta,
    siteUrl: 'https://example.com',
    locale: 'auto',
  })

  return (
    <div className="teemseo">
      {result && <ScoreBadge rating={result.overallScore} locale={result.messageLocale} />}
      <MetaFields value={meta} onChange={onChangeMeta} locale="fa" />
      <SnippetPreview {...meta} siteUrl="https://example.com" locale="fa" />
      <AnalysisPanel result={result} loading={loading} locale="fa" />
      <SeoAccordions content={content} value={meta} onChange={onChangeMeta} locale="fa" siteUrl="https://example.com" />
    </div>
  )
}

Also exported: Accordion, ScoreLight.

Styling

Import teem-seo/styles.css. All styles are scoped under .teemseo and use CSS variables you can override:

.teemseo {
  --teemseo-accent: #0f766e;
  --teemseo-good: #15803d;
  --teemseo-ok: #ca8a04;
  --teemseo-bad: #dc2626;
  --teemseo-radius: 10px;
  --teemseo-font: "Vazirmatn", sans-serif;
}

Wrap TeemSEO in a container with max-width if the default 420px sidebar width does not fit your layout.

Integration patterns

CMS / blog editor

  1. Store content (HTML), title, metaDescription, slug, focusKeyphrase in your CMS.
  2. Render <TeemSEO content={html} siteUrl={SITE_URL} ... /> beside the editor.
  3. Use onChangeMeta to write meta field changes back to CMS state.
  4. Optionally wire isKeyphraseUsedElsewhere to your posts table.
  5. Implement getInternalLinkSuggestions with your search index or SQL.

Analysis-only mode

Show scores without editable fields (e.g. preview mode):

<TeemSEO
  content={html}
  focusKeyphrase={kp}
  title={title}
  metaDescription={desc}
  slug={slug}
  siteUrl={SITE_URL}
  analysisOnly
/>

Server-side gate before publish

import { analyzeSync } from 'teem-seo'

const result = analyzeSync({ content, focusKeyphrase, title, metaDescription, slug, siteUrl })
const blockers = result.seo.filter((a) => a.rating === 'bad')
if (blockers.length > 0) {
  throw new Error(`SEO issues: ${blockers.map((b) => b.text).join('; ')}`)
}

Local demo

git clone <repo>
cd TeemSEO
npm install
npm run build
cd examples/demo && npm install && npm run dev

The demo switches between English and Persian sample content and shows live analysis + internal link suggestions.

License

MIT