How to Build a Scalable Framework-Agnostic Web SDKs: Step by Step

In today’s diverse frontend ecosystem, building tools that cater to a single framework can limit their reach and usability. Not only this, you may also want to target the whole frontend ecosystem, without limitations related to libs and frameworks.

Framework-agnostic SDKs offer a solution by ensuring compatibility across multiple frameworks while maintaining performance and simplicity.

This blog post aims to guide you through the process of creating a framework-agnostic web SDK, from initial setup to deployment. Whether you’re a seasoned developer or new to SDK design, you’ll gain insights and practical tips to make your SDK accessible to developers everywhere.

What Does Framework-Agnostic Mean?

A framework-agnostic SDK is designed to work seamlessly across multiple frameworks, such as React, Vue, Angular, or even vanilla JavaScript.

The goal is to maximize usability and minimize dependency on specific tools or libraries, ensuring that developers working in various ecosystems can integrate your SDK without friction.

This approach not only broadens your SDK’s audience but also simplifies maintenance and reduces versioning headaches.

Key aspects of a framework-agnostic SDK:

  • Neutral Core: Avoid dependencies on specific frameworks. For example, instead of using React-specific hooks, rely on plain JavaScript for core logic.
  • Adaptability: Offer optional framework-specific wrappers (e.g., a React provider or Vue plugin) for ease of use in specific environments.
  • Portability: Ensure compatibility with browsers, Node.js, and tools like Webpack, Rollup, or Vite.

Why Build Framework-Agnostic SDKs?

Why Build Framework-Agnostic SDKs

Choosing the Right Tools and Technologies

Selecting the right stack is critical when building a framework-agnostic SDK. Here’s a breakdown:

  • Language: Use TypeScript for type safety and a better developer experience. Type annotations make your SDK more robust and self-documenting.
  • Bundler: Tools like Rollup or Vite are optimized for building libraries. They support tree-shaking, multiple output formats (ESM, CommonJS, UMD), and plugins for enhanced functionality. In this guide, we will be using Vite.
  • Standards: Leverage modern JavaScript features like ES Modules and async/await, but ensure compatibility with older environments through transpilers like Babel, or compile the code with older targets in mind.
  • Testing Frameworks:
    • Unit Testing: Use Jest or Vitest for core logic.
    • Integration Testing: Tools like Playwright or Cypress can verify SDK behavior in real-world scenarios across frameworks.
  • Documentation Tools: Generate rich, user-friendly documentation with Storybook, Docusaurus, or Typedoc.

Core Considerations and API Design

A well-designed API is the cornerstone of a successful SDK. So focus on:

  • Simplicity: Keep function names intuitive and parameters consistent.
  • Flexibility: Allow customization without overwhelming users with options.
  • Minimalism: Start with essential features and expand based on user feedback.
  • Error Handling: Provide meaningful error messages and edge-case handling to avoid confusion during implementation.

Modularity

Backward Compatibility

  • Use semantic versioning to communicate breaking changes.
  • Maintain deprecated features for at least one major version before removing them.

Documentation

  • Code Examples: Include ready-to-use examples for common use cases.
  • Setup Guides: Cover integration steps for popular frameworks.
  • API Reference: Provide detailed descriptions of each method, parameter, and return value.

Let’s give a quick example of what a framework-agnostic SDK would look like.

Keyboard Shortcut SDK

For this example, we will create an SDK for managing custom keyboard shortcuts across a web application, allowing developers to easily bind and unbind shortcuts to specific actions.

Main Features

  1. Register Global Shortcuts
    • Easily assign global keyboard shortcuts (e.g., Ctrl + S) to trigger specific functions.
  2. Context-Specific Shortcuts
    • Enable shortcuts only when certain conditions are met (e.g., when a modal or a specific element is active).
  3. Unregister Shortcuts
    • Dynamically remove shortcuts when they’re no longer needed.
  4. Visual feedback
    • Show an opt-in temporary tooltip when registering the shortcuts.

A good way to start is to define how you will use the SDK. Given the features above, we should use the SDK as follows:

1. Import and Initialize the SDK

import { initialize } from './keyboardShortcutSDK';


initialize();Code language: JavaScript (javascript)

2. Register Global Shortcuts

<code>import { keyboardShortcutSDK } from './keyboardShortcutSDK';

keyboardShortcutSDK().register('Ctrl+S', () => {
  console.log('Save triggered!');
  alert('Document saved!');
});</code>Code language: JavaScript (javascript)

3. Register Context-Specific Shortcuts

import { keyboardShortcutSDK } from './keyboardShortcutSDK';

const modalIsOpen = () => document.querySelector('#myModal').classList.contains('open');

keyboardShortcutSDK.register('Escape', () => {
  console.log('Modal closed!');
  document.querySelector('#myModal').classList.remove('open');
}, { condition: modalIsOpen });Code language: JavaScript (javascript)

4. Unregister Shortcuts

import { keyboardShortcutSDK } from './keyboardShortcutSDK';

keyboardShortcutSDK.unregister('Ctrl+S'); // Remove the save shortcutCode language: JavaScript (javascript)

5. Show a tooltip when registering a shortcut

import { keyboardShortcutSDK } from './keyboardShortcutSDK';

keyboardShortcutSDK.register('Ctrl+S', () => {
  console.log('Save triggered!');
  alert('Document saved!');
}, { feedback: { enabled: true, message: "Press 'Ctrl+S' to save." } });Code language: JavaScript (javascript)

For example purposes, the SDK will be whole exported in keyboardShortcutSDK, but keep in mind modularity when creating your SDK, as we saw in the core considerations session.

Initializing Vite project

Let’s initialize the Vite project:

npm create vite@latestCode language: JavaScript (javascript)

Give it a name when prompted. Select ‘Vanilla’ when prompted for a framework (which means no specific framework will be used), and then choose TypeScript as the variant.

Initializing Vite project

Follow the instructions given by Vite’s cli: go to the folder created with the project name and run `npm install` (or use any package manager you want).

After that, you can run npm run dev, and open http://localhost:5173 in your browser to see the following:

Fun fact: Do you know why Vite uses the port 5173? If you try to read it as letters, you will read SITE!

If you open your project in the editor/IDE of your choice, you will see the following structure:

The first thing to do is to tweak the configurations.

Update TSConfig

Open tsconfig.json file, and add the following to the compilerOptions object:

"declaration": true,
"declarationMap": true,
"sourceMap": trueCode language: JavaScript (javascript)

Those configs will export the declaration files for the lib when compiling, and also will export the source maps, which help with debugging steps.

Also, add the app folder to the include array. We will be creating this folder in the next steps. The configuration file should look like the following:

{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": [
      "ES2020",
      "DOM",
      "DOM.Iterable"
    ],
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true,
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedSideEffectImports": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": [
    "src",
    "app"
  ]
}Code language: JavaScript (javascript)

After that, create a new file called `tsconfig.build.json` with the following content:

{
  "extends": "./tsconfig.json",
  "include": [
    "src"
  ]
}Code language: JavaScript (javascript)

When building, this will override the include list to include only the SDK source code.

Update Vite config

Now, let’s create a file called vite.config.ts. This is the file responsible for the configurations of the Vite project. It’s here where we will configure Vite to the lib mode.

Before creating it, let’s add the development dependencies we will need:

  • @types/node: types for node packages
  • vite-plugin-dts: the Vite plugin responsible for generating our *.d.ts files:
npm i -D @types/node@latest vite-plugin-dtsCode language: JavaScript (javascript)

Then, create the vite.config.ts file with the following contents:

import { resolve } from "path"
import { defineConfig } from "vite"
import dts from "vite-plugin-dts"

export default defineConfig({
  plugins: [dts({ include: ["src"] })],
  build: {
    copyPublicDir: false,
    lib: {
      entry: resolve(__dirname, "src/index.ts"),
      name: "keyboard-shortcut-sdk",
      formats: ["es", "cjs", "umd"],
      fileName: (ext) => `index.${ext}.js`,
    },
    sourcemap: true,
  },
})Code language: JavaScript (javascript)

Moving things around

Till now, you have had a file structure like this:

We need to move what we don’t want in the SDK to the app folder. Let’s then create this folder and move the index.html file there.

Also, let’s remove the code we will not be using:

  • Remove the files: counter.ts, style.css, and typescript.svg from the src folder

Rename main.ts to index.ts, and update the content with the following:

export function helloAnything(thing: string): string {
  return `Hello, ${thing}!`
}Code language: JavaScript (javascript)

Now you can observe that if you try to start the server again, you’ll see nothing in the browser. But if you try to access the route /app/index.html, the index.html file will load. Let’s fix that. In the vite.config.ts file, add the following to the plugins list:

{
  name: "deep-index",
  configureServer(server) {
    server.middlewares.use((req, res, next) => {
      if (req.url === "/") {
        req.url = "/app/index.html"
      }
      next()
    })
  },
},Code language: JavaScript (javascript)

Also, inside the app folder, create a new file called main.ts, and add the following content:

import { helloAnything } from "../src"

console.log(helloAnything("world"))Code language: JavaScript (javascript)

And update the script importing in the index.html accordingly:

<script type="module" src="/app/main.ts"></script>  <!-- it was /src/main.ts before updating -->Code language: JavaScript (javascript)

Now, you are able to open the webpage. It will be a blank screen. If you open the console, you should see Hello, world! printed.

And this is the file structure now:

Coding main features

Now that we have the project structure prepared, let’s develop the features of the SDK.

Initialize SDK

Let’s create the initializeSdk function. It will serve only to create the SDK instance to be used later. But you can use this function to save an access token, for example, or save some initial configs that your solution could be using. Let’s create the domain folder, where we will be adding all our business logic.

And inside that, let’s add an sdk folder, focused on the SDK main class. There, add the index.ts and put the following code:

export class KeyboardShortcutSDK {
  private static instance: KeyboardShortcutSDK

  static getInstance() {
    if (!KeyboardShortcutSDK.instance) {
      throw new Error("KeyboardShortcutSDK is not initialized")
    }
    return KeyboardShortcutSDK.instance
  }

  static initialize() {
    KeyboardShortcutSDK.instance = new KeyboardShortcutSDK()
    return KeyboardShortcutSDK.instance
  }
}Code language: JavaScript (javascript)

Now, update the src/index.ts file content to the following:

import { KeyboardShortcutSDK } from "./domain/sdk"

export function initializeSdk() {
  return KeyboardShortcutSDK.initialize()
}

export function keyboardShortcutSDK() {
  return KeyboardShortcutSDK.getInstance()
}Code language: JavaScript (javascript)

You will see that the IDE will start complaining about app/main.ts. Let’s update it also to call our initialize function:

import { initializeSdk } from "../src"

initializeSdk()Code language: JavaScript (javascript)

Register Shortcuts method

Now that we already have the SDK class, let’s add the function to register the shortcuts.

Add this type somewhere (I added it before the SDK class definition):

type KeyboardShortcut = {
  key: string
  ctrlKey: boolean
  shiftKey: boolean
  altKey: boolean
  metaKey: boolean
  callback: () => void
  options?: {
    condition?: () => boolean
    feedback?: string
  }
  id: string
}Code language: JavaScript (javascript)

Add the following code to the SDK class:

  private shortcuts: KeyboardShortcut[] = []
  private initializedShortcutsListener = false
  
  private parseShortcut(shortcut: string) {
    const shortcutPieces = shortcut.toLowerCase().split("+")
    if (shortcutPieces.length > 2) {
      throw new Error(
        'Invalid shortcut. Use at most a modifier and a key. Example: "ctrl+s"'
      )
    }

    const [modifierOrKey, key] = shortcutPieces

    const ctrlKey = modifierOrKey === "ctrl"
    const shiftKey = modifierOrKey === "shift"
    const altKey = modifierOrKey === "alt"
    const metaKey = modifierOrKey === "meta"
    return {
      key: key || modifierOrKey,
      ctrlKey,
      shiftKey,
      altKey,
      metaKey,
      id: shortcut,
    }
  }

  private initializeShortcutsListener() {
    const onKeyPressed = (event: KeyboardEvent) => {
      const shortcut = this.shortcuts.find((shortcut) => {
        return (
          shortcut.key === event.key &&
          shortcut.ctrlKey === event.ctrlKey &&
          shortcut.shiftKey === event.shiftKey &&
          shortcut.altKey === event.altKey &&
          shortcut.metaKey === event.metaKey
        )
      })
      
      const shouldRun = shortcut?.options?.condition?.() ?? true

      if (shortcut && shouldRun) {
        event.preventDefault()
        shortcut.callback()
      }
    }

    document.addEventListener("keydown", onKeyPressed)
  }

  register(
    shortcut: string,
    callback: () => void,
    options?: {
      condition?: () => boolean
      feedback?: string
    }
  ) {
    this.shortcuts.push({
      ...this.parseShortcut(shortcut),
      callback,
      options,
    })

    if (!this.initializedShortcutsListener) {
      this.initializedShortcutsListener = true
      this.initializeShortcutsListener()
    }
  }Code language: JavaScript (javascript)

Explanation:

  • Shortcuts will store all registered shortcuts
  • initializedShortcutsListener is a flag to check if we have initialized the listener for shortcuts
  • initializeShortcutsListener() will initialize the listener the first time a shortcut is registered.
  • parseShortcut() will parse the string passed in the register method to check for modifiers. For simplicity, we will allow only shortcuts with a key or modifier+key.
  • register() is the main method where we will be registering shortcuts. It accepts, respectively:
    • A string, which will be parsed, representing the shortcut
    • A callback, which will be called when the shortcut is triggered
    • An options object, which is optional, and has a condition property, which is a callback that is executed to check if the shortcut should run or not, and a feedback property, which is a string, and, if present, the SDK will show a toast every time the shortcut triggers.

Now we can register our first shortcut in the app/main.ts file:

import { initializeSdk, keyboardShortcutSDK } from "../src"

initializeSdk()

keyboardShortcutSDK().register("ctrl+s", () => {
  alert("Saved!")
})Code language: JavaScript (javascript)

If you run the project and press “Ctrl+S“ on the keyboard, you should see the alert appear.

Adding feedback

Let’s add a feedback code for when the shortcut is triggered.

First of all, we will be using TailwindCSS to demonstrate CSS injection. Following the instructions from TailwindCSS website:

  • Install the necessary packages:
<code>npm install -D tailwindcss @tailwindcss/vite</code>Code language: JavaScript (javascript)
  • Add the plugin to vite.config:
    • Add import:
plugins: [
  tailwindcss(),
  dts(...),
  ...
]Code language: JavaScript (javascript)
  • Add it to the plugins list:
<code>plugins: [ tailwindcss(), dts(...), ... ]</code>Code language: JavaScript (javascript)
  • Create a styles.css file into src and add the import to tailwind:
<code>@import "tailwindcss"</code>Code language: JavaScript (javascript)
  • Add the import to styles.css to the src/index.ts file:
<code>import './styles.css'</code>Code language: JavaScript (javascript)

Let’s then also add some content to our example app. Change the body content of your index.html to the following:

<body>
  <div class="w-full h-screen bg-slate-800 text-white flex flex-col justify-center items-center">
    <h1 class="text-2xl font-bold">Keyboard Shortcuts SDK</h1>
    <h3 class="text-lg italic">Example app</h3>

    <button id="register-save-shortcut" class="mt-10 px-4 py-2 rounded-md bg-amber-700">Register Save shortcut</button>

    <p id="save-shortcut-content" class="hidden mt-10 text-gray-300">
      Press
      <span class="text-white font-bold">"Ctrl+S"</span>
      for a shortcut demonstration
    </p>
  </div>
  <script type="module" src="/app/main.ts"></script>
</body>Code language: JavaScript (javascript)

It should now render the following page:

Let’s then add the vite-plugin-css-injected-by-js dependency, which will be responsible for adding the generated styles of our lib injected in our JS output:

npm i -D vite-plugin-css-injected-by-jsCode language: JavaScript (javascript)

And update the plugins list in Vite:

import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js"Code language: JavaScript (javascript)
plugins: [
  tailwindcss(),
  cssInjectedByJsPlugin(),
  dts(...),
  ...
]Code language: JavaScript (javascript)

Now, if you run npm run build the result should contain a function that adds the styles dynamically. Like the following:

But if you search for the class w-full, which we are using in our index.html file (which is an example page only), it will be in our generated file. And we don’t want this. We want to include only styles from our library in our output. To do that, because of the way Tailwind v4 is designed, we need to have multiple styles.css files, one for development purposes with our example page, and another for building the SDK.

Let’s rename the styles.css file to styles.dev.css and keep the content as is. Also, let’s create another styles.css file now, with the following content:

@import "tailwindcss" source(none);

@source "../src";Code language: CSS (css)

The source(none) will change the content that tailwind expects to check to be nothing (by default, it will check for everything except for git-ignored files and some others – refer to Tailwind documentation). After this, we do a @source "../src"; which tells Tailwind that we want to include the src folder.

Also, to load one file or another dynamically, let’s create the loadStyles.ts file with the following:

function loadStyles() {
  if (import.meta.env.DEV) {
    import("./styles.dev.css")
  } else {
    import("./styles.css")
  }
}

loadStyles()
Code language: JavaScript (javascript)

In the index.ts file, change the import to ./styles.css file to be:

import "./loadStyles"Code language: JavaScript (javascript)

Now, if you run npm run build, you will not see the w-full class name present in the output files anymore. This way, we can add feedback for when a shortcut is registered and have only the desired styles in the output files.

Given this, let’s update our SDK to show a toast when the user passes a feedback string that shows up when the shortcut is registered.

Add the following method to our KeyboardShortcutSDK class:

  private shortcutAddedFeedback(feedback: string) {
    const toast = document.createElement("div")
    toast.innerHTML = `
      <div class="fixed inset-x-0 top-4 flex items-end justify-center px-4 py-6 pointer-events-none sm:items-start sm:px-6 sm:py-4 sm:justify-end sm:space-x-4 sm:top-6 sm:right-6">
        <div class="z-50 flex items-center w-full max-w-xs p-4 text-gray-500 bg-white rounded-lg shadow-sm dark:text-gray-400 dark:bg-gray-900" role="alert">
          <div class="ms-3 text-sm font-normal">${feedback}</div>
        </div>
      </div>
    `
    document.body.appendChild(toast)
    setTimeout(() => {
      document.body.removeChild(toast)
    }, 3000)
  }Code language: JavaScript (javascript)

Also, update the register method to call it when the options.feedback is defined. Add the following lines to the end of the register method:

    if (options?.feedback) {
      this.shortcutAddedFeedback(options.feedback)
    }Code language: JavaScript (javascript)

To make it in action, let’s also update our app/main.ts to call the register only when the user clicks on the “Register Save shortcut“ button, and to include a feedback string:

import { initializeSdk, keyboardShortcutSDK } from "../src"

initializeSdk()

const buttonToRegisterShortcut = document.getElementById(
  "register-save-shortcut"
)
const saveShortcutContent = document.getElementById("save-shortcut-content")

function registerShortcut() {
  keyboardShortcutSDK().register(
    "ctrl+s",
    () => {
      alert("Saved!")
    },
    {
      feedback: 'Shortcut "ctrl+s" registered',
    }
  )

  saveShortcutContent?.classList.remove("hidden")
  buttonToRegisterShortcut?.classList.add("hidden")
}

buttonToRegisterShortcut?.addEventListener("click", registerShortcut)Code language: JavaScript (javascript)

Now, if you click on the button, you will have the following:

Now, if you run npm run build, it will only include the styles you have in the SDK source code.

Unregistering

To make an unregister method, let’s add the following to the KeyboardShortcutSDK class:

  unregister(shortcut: string) {
    const index = this.shortcuts.findIndex((s) => s.id === shortcut)
    if (index !== -1) {
      this.shortcuts.splice(index, 1)
    }
  }Code language: JavaScript (javascript)

Selecting what will be delivered with our SDK

NPM has the npm pack command, which is responsible for generating a .tgz file with the package contents and everything deliverable in your package.

If you run this command and check the contents inside the .tgz file, you will see that everything was included in the package, including the development app and the src folder.

Since we are compiling everything, we want to deliver only the compiled files, so that we can keep the package as small as it can. To fix it, let’s add something to the package.json file.

Add the following to your package.json:

  "main": "dist/index.es.js",
  "module": "dist/index.es.js",
  "types": "dist/index.d.ts",
  "files": [
    "dist"
  ],Code language: JavaScript (javascript)

Now, if you run npm run build, npm pack again, and check the .tgz file content, you will have something like this:

You can also use the .npmignore file to exclude some files from the final package. It works similarly to the .gitignore file.

Next Steps and Good Practices

Adding a README

A good practice is to keep an updated README file for your SDK/Library. Let’s add one to ours:

Best practices and additional information

Some more good practices and additional information you should keep in mind when building an SDK:

  • Add tests: Tests are critical to maintain consistency between version management in an SDK. They also help keep the quality of your source code.
  • Add documentation: As the SDK grows, probably a README file will not be enough in terms of documentation. The use of a tool that helps build documentation, like Typedoc, may be a good option, but you can keep markdown files inside a docs folder too.
  • Code Styling: The use of tools like ESLint, Prettier, and Editor Config is very recommended for any project. In an SDK context, it is even more recommended, since if you are open-sourcing it, many people can contribute to the code, and with these tools, the code can be kept in the same style. Tools like Husky and Lint Staged are strongly recommended also.
  • Package publishing: You can refer to the NPM documentation on publishing your package on the NPM Registry.

Conclusion

Framework-agnostic SDKs represent a leap toward inclusive and sustainable software development. By adhering to best practices and focusing on universal design principles, you can create tools that empower developers, regardless of their framework preferences.

Start building your framework-agnostic SDK today, and watch as your tool becomes a cornerstone of countless projects!

About the author.