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

@better-i18n/sdk

v3.5.0

Published

Content SDK for Better i18n - headless CMS client for fetching content models and entries

Readme

@better-i18n/sdk

Content SDK for Better i18n. A lightweight, typed client for fetching content models and entries from the headless CMS.

Features

  • 📦 Zero Dependencies — Runs anywhere with fetch()
  • 🔒 Type-Safe — Full TypeScript types with generic custom fields
  • 🌍 Language-Aware — Fetch localized content by language code
  • 📄 Pagination Built-in — Paginated listing with total count and hasMore
  • 🔍 Filtering, Search & Sorting — Filter by status, custom fields, full-text search
  • ⚡ Chainable Query Builder — Supabase-style from().eq().order().limit() API
  • 🛡️ Error-Safe — { data, error } pattern — never throws on API errors

Installation

npm install @better-i18n/sdk

Quick Start

import { createClient } from "@better-i18n/sdk";

const client = createClient({
  project: "acme/web-app",
  apiKey: process.env.BETTER_I18N_API_KEY!,
});

// List published blog posts (chainable API)
const { data: posts, error, total, hasMore } = await client
  .from("blog-posts")
  .eq("status", "published")
  .order("publishedAt", { ascending: false })
  .limit(10)
  .language("en");

// Get a single entry
const { data: post, error: postError } = await client
  .from("blog-posts")
  .expand("author", "category")
  .language("fr")
  .single("hello-world");

if (post) {
  console.log(post.title, post.body);
}

Chainable Query Builder

The from() method returns an immutable, chainable query builder — inspired by Supabase JS.

Every chain method returns a new builder instance, so you can safely reuse base queries:

const publishedPosts = client
  .from("blog-posts")
  .eq("status", "published");

// Reuse the base query
const recentPosts = await publishedPosts
  .order("publishedAt", { ascending: false })
  .limit(5);

const popularPosts = await publishedPosts
  .order("title", { ascending: true })
  .limit(10);

Chain Methods

| Method | Description | Example | | --- | --- | --- | | .select(...fields) | Choose which fields to include | .select("title", "body") | | .eq(field, value) | Filter by built-in field (status, etc.) | .eq("status", "published") | | .filter(field, value) | Filter by custom field value | .filter("category", "engineering") | | .search(term) | Full-text search on entry titles | .search("kubernetes") | | .language(code) | Set language for localized content | .language("fr") | | .order(field, opts?) | Set sort field and direction | .order("publishedAt", { ascending: false }) | | .limit(n) | Max entries per page (1-100) | .limit(10) | | .page(n) | Page number (1-based) | .page(2) | | .expand(...fields) | Expand relation fields | .expand("author", "category") |

Terminal Methods

| Method | Description | Returns | | --- | --- | --- | | .single(slug) | Fetch a single entry by slug | { data, error } | | await builder | Execute list query (thenable) | { data, error, total, hasMore } |

Response Types

List query returns QueryResult<T[]>:

{
  data: ContentEntryListItem[] | null;  // null on error
  error: Error | null;                   // null on success
  total: number;                         // total matching entries
  hasMore: boolean;                      // more pages available
}

Single query returns SingleQueryResult<ContentEntry>:

{
  data: ContentEntry | null;  // null on error
  error: Error | null;        // null on success
}

Examples

// Full-text search
const { data: results } = await client
  .from("blog-posts")
  .search("kubernetes")
  .limit(5);

// Custom field filtering
const { data: techPosts } = await client
  .from("blog-posts")
  .filter("category", "engineering")
  .eq("status", "published")
  .language("en");

// Select specific fields + expand relations
const { data: entries } = await client
  .from("blog-posts")
  .select("title", "body")
  .expand("author")
  .order("publishedAt", { ascending: false });

// Pagination
const { data: page2, hasMore } = await client
  .from("blog-posts")
  .eq("status", "published")
  .limit(20)
  .page(2);

Error Handling

The chainable API uses the { data, error } pattern — it never throws on API errors:

const { data, error } = await client
  .from("blog-posts")
  .eq("status", "published")
  .limit(10);

if (error) {
  console.error("Failed to fetch posts:", error.message);
  return;
}

// data is guaranteed non-null here
console.log(`Found ${data.length} posts`);

Typed Custom Fields

Custom fields are returned flat — directly on the entry object (no customFields wrapper). Use the generic type parameter for type-safe access:

interface BlogFields {
  readingTime: string | null;
  category: string | null;
}

const { data: post } = await client
  .from("blog-posts")
  .single<BlogFields>("hello-world");

if (post) {
  post.readingTime; // string | null (typed!)
  post.category;    // string | null (typed!)
}

Expanding Relations

Use expand to resolve related entries in a single request:

const { data: posts } = await client
  .from("blog-posts")
  .expand("author", "category")
  .eq("status", "published");

if (posts) {
  posts[0].relations?.author?.title;    // "Alice Johnson"
  posts[0].relations?.author?.avatar;   // "https://..."
  posts[0].relations?.category?.title;  // "Engineering"
}

Each expanded relation is a RelationValue object with custom fields spread directly:

type RelationValue = {
  id: string;
  slug: string;
  title: string;
  modelSlug: string;
} & Record<string, string | null>;

Content Models & Fields

getModels() returns model definitions including custom field metadata — useful for building dynamic forms and tables:

const models = await client.getModels();

for (const model of models) {
  console.log(model.slug, model.displayName);
  console.log("Has body field:", model.includeBody);

  for (const field of model.fields) {
    console.log(`  ${field.name} (${field.type})`);

    // Enum fields include their allowed values
    if (field.type === "enum" && field.enumValues) {
      console.log("    Options:", field.enumValues.map((v) => v.label));
    }
  }
}

ContentModel

| Field | Type | Description | | --- | --- | --- | | slug | string | Model identifier | | displayName | string | Human-readable name | | description | string \| null | Model description | | kind | string | "collection" or "single" | | entryCount | number | Number of entries | | includeBody | boolean | Whether model has a body/rich-text field | | fields | ContentModelField[] | Custom field definitions |

ContentModelField

| Field | Type | Description | | --- | --- | --- | | name | string | Field identifier (snake_case) | | displayName | string | Human-readable name | | type | string | text, textarea, richtext, number, boolean, date, datetime, enum, media, relation | | required | boolean | Whether field is required | | localized | boolean | Whether field is localized per language | | enumValues | Array<{ label, value }> | Enum options (only for type: "enum") |

Legacy API

The method-based API is still available for backward compatibility:

// List content models (now includes fields and includeBody)
const models = await client.getModels();

// List entries (throws on error)
const { items, total, hasMore } = await client.getEntries("blog-posts", {
  status: "published",
  sort: "publishedAt",
  order: "desc",
  language: "en",
  limit: 10,
});

// Get single entry (throws on error)
const post = await client.getEntry("blog-posts", "hello-world", {
  language: "fr",
});

Configuration

| Option | Required | Description | | --- | --- | --- | | project | Yes | Project identifier in org/project format (e.g., "acme/web-app") | | apiKey | Yes | API key from dashboard | | apiBase | No | API base URL (default: https://content.better-i18n.com) | | debug | No | Enable debug logging for request/response inspection |

Documentation

Full documentation at docs.better-i18n.com/sdk

License

MIT © Better i18n