Lessons from building and maintaining an NPM package that powers tier-0 OTT production apps across millions of users.
When I started building a frontend SDK, one that would eventually be consumed across a fleet of tier-0 OTT production applications, collectively handling more than 10 million active users, I quickly realized something uncomfortable.
The hardest part wasn’t the code. It was every decision around the code.
How you name a method. What you return when something goes wrong. Whether you force consumers to configure something they shouldn’t have to think about. These are the things that either make a developer’s day easier, or quietly ruin it at 2am during a production incident.
This article is everything I wish I’d known before shipping that first version.
1. Your API Surface Is a Promise
The moment another team installs your package and writes import { authenticate } from '@your-org/sdk', you've made a promise. That method name, its parameters, its return shape are now load-bearing walls in someone else's application.
Keep the surface small. Export only what needs to be public. Every extra export is a contract you now have to honour forever, or break with a major version bump.
// ❌ Exporting internal utilities "just in case"
export { tokenParser, refreshQueue, buildAuthHeader }
// ✅ Only expose what consumers actually need
export { authenticate, logout, getUser }
If an internal utility leaks into the public API and someone starts depending on it, you can’t quietly remove it. You’ve created a hidden contract.
Name things from the consumer’s perspective, not yours. You know how the internals work. They don’t, and they shouldn’t have to.
2. Sensible Defaults Are an Act of Respect
Every required config option you add is a question your SDK is asking the consumer to answer. Sometimes that’s necessary. Most of the time, it isn’t.
Ask yourself for every config field: does the developer actually know what to set here, and does it vary meaningfully across use cases?
// ❌ Forcing the consumer to configure things they shouldn't care about
const sdk = new AuthSDK({
tokenStorageStrategy: 'memory',
refreshBuffer: 60,
retryAttempts: 3,
cookieSameSite: 'Lax'
})
// ✅ Sane defaults. Override only when needed.
const sdk = new AuthSDK({
clientId: 'your-client-id'
})
The defaults should represent the most secure, most correct behaviour out of the box. The consumer shouldn’t have to research best practices just to get started safely.
In our Authentication SDK, access tokens are stored in memory by default, not localStorage, not sessionStorage. That’s the right call for XSS resistance. A developer shouldn’t have to know about XSS attack surfaces to benefit from that decision. They just get it.
3. Error Messages Are Documentation
When something goes wrong in your SDK, you have a few seconds of the developer’s attention. Use them.
// ❌ An error that tells you nothing useful
throw new Error('Auth failed')
// ✅ An error that tells you what happened, why, and what to check
throw new Error(
'[AuthSDK] Token refresh failed: the refresh token has expired or is invalid. ' +
'This usually happens after a long inactive session. Call sdk.login() to re-authenticate.'
)
A few things that make error messages genuinely useful:
- Prefix with your SDK name: when this surfaces in a Sentry report, developers know immediately where to look.
- Explain the why, not just the what: “invalid token” is useless; “token expired after 24 hours of inactivity” is actionable.
- Suggest a fix: if there’s a clear resolution path, just say it.
For errors that consumers are expected to handle, create typed error classes:
export class TokenExpiredError extends Error {
constructor() {
super('[AuthSDK] Access token has expired. Call sdk.refreshToken() or sdk.login().')
this.name = 'TokenExpiredError'
}
}
export class NetworkError extends Error {
constructor(public statusCode: number) {
super(`[AuthSDK] Network request failed with status ${statusCode}.`)
this.name = 'NetworkError'
}
}
Now the consumer can do this cleanly:
try {
await sdk.getUser()
} catch (err) {
if (err instanceof TokenExpiredError) {
await sdk.login()
}
}
4. TypeScript Types Are the First Layer of Documentation
Your types aren’t just for type safety. They are the first thing a developer reads about your SDK, before they open a single doc page.
If your types are vague, your SDK feels unreliable.
// ❌ Vague types that tell the consumer nothing
function authenticate(config: object): Promise<any>
// ✅ Types that tell the full story at a glance
interface AuthConfig {
clientId: string
/** Defaults to 'memory'. Use 'cookie' only for SSR environments. */
tokenStorage?: 'memory' | 'cookie'
/** Called automatically when access token expires */
onTokenRefresh?: (newToken: string) => void
}
interface AuthResult {
user: User
accessToken: string
expiresAt: number // Unix timestamp
}
function authenticate(config: AuthConfig): Promise<AuthResult>
Notice the JSDoc comments inside the interface. That’s the next point.
5. Documentation-as-Code: Write It Where Developers Actually Read It
Developers don’t always open the docs site. They hover over a function in VS Code and read the tooltip. That’s your real documentation surface.
Write JSDoc comments directly in your type definitions and function signatures:
/**
* Authenticates the user and stores the session.
*
* @param config - Authentication configuration
* @returns Resolved with the authenticated user and token details
*
* @throws {TokenExpiredError} If the existing session has expired
* @throws {NetworkError} If the auth endpoint is unreachable
*
* @example
* const { user } = await sdk.authenticate({ clientId: 'abc123' })
* console.log(user.email)
*/
function authenticate(config: AuthConfig): Promise<AuthResult>
The @example block is particularly valuable. When a developer is evaluating whether your SDK does what they need, a concrete working example in the tooltip closes that question in 5 seconds.
6. Versioning Is a Communication Tool
A version number carries meaning. When you bump it, you’re telling your consumers something.
- Patch (1.0.x): bug fix, nothing changes for them
- Minor (1.x.0): new capability, fully backwards compatible
- Major (x.0.0): something they depend on changed, they need to act
When we had a breaking change in our authentication flow, we did three things:
- Bumped the major version
- Added a clear migration guide in the CHANGELOG
- Kept the old method working for one minor version, with a console warning pointing to the new one
/** @deprecated Use sdk.login() instead. Will be removed in v4.0. */
function authenticate() {
console.warn('[AuthSDK] authenticate() is deprecated. Use login() instead.')
return login()
}
Deprecation warnings are a courtesy your consumers will remember.
7. What Changes in Your SDK Can Break Production - Know That Weight
When your package is used across tier-0 applications serving millions of users, a broken release isn’t just embarrassing. It’s a real incident. Here’s what that forces you to discipline:
- Never release without testing in a consumer app. Snapshot tests in your own repo aren’t enough. Use a real integration.
- Lock peer dependencies carefully. If your SDK assumes React 17 but the consumer has 18, that mismatch will surface at the worst possible time.
- Changelogs are not optional. Every release should have one, even patch bumps. Consumers need to know what changed.
- Don’t put secrets or environment-specific logic in the SDK core. Config should come from the consumer. Your SDK should be environment-agnostic.
Final Thoughts
The best SDK is one that feels obvious. Where a developer can sit down with it for the first time and get something working without reading a single doc page, because the API told them what to do, the defaults were sensible, and the error message told them what they got wrong.
That’s a hard bar to hit. But every time you name a method from the consumer’s perspective, write a JSDoc example, or turn a cryptic error into a useful one, you get closer.
The developers consuming your SDK are trusting you with their production apps. That trust is worth protecting.
Have thoughts or questions on SDK design? Drop them in the comments, always happy to discuss.
Top comments (0)