Skip to content
ExponaBLOG
SVELTE & SVELTEKIT · DAY 099 / 100

Writing reusable Svelte libraries in Svelte & SvelteKit

Share components as packages.

Expona Admin7 min read

Introduction

As your Svelte and SvelteKit application grows, you'll inevitably reach a point where you want to extract reusable components and share them across multiple projects, teams, or even the open-source community. Writing reusable Svelte libraries in Svelte & SvelteKit transforms isolated components into shareable, versioned packages that your team can depend on, reducing duplication and establishing consistent UI patterns across your organization.

Building a proper library requires more than just copying files into npm. You need a structured workflow, clear exports, proper TypeScript support, and documentation that helps other developers (including your future self) understand what they're using. This article covers the complete process of creating production-ready Svelte libraries.

Understanding svelte-package

The foundation of any Svelte library is svelte-package, a build tool that comes with SvelteKit designed specifically for creating distributable packages. When you run npm create svelte@latest my-lib -- --template=lib, you get a pre-configured project with the right structure and tooling already in place.

The key difference between a regular SvelteKit app and a library template is the presence of a svelte.config.js configured with the library preset and a src/lib directory as your package root. When you build with svelte-package, it compiles your Svelte components to JavaScript, generates TypeScript declarations, and produces both ESM and CommonJS output formats.

Here's what a minimal svelte.config.js looks like for a library:

typescript
import adapter from '@sveltejs/adapter-auto';

export default {
  kit: {
    adapter: adapter(),
    package: {
      exports: {
        '.': './src/lib/index.ts',
        './Button': './src/lib/Button.svelte',
        './Card': './src/lib/Card.svelte'
      },
      files: src => !src.includes('node_modules')
    }
  }
};

The package.exports field explicitly defines what consumers can import from your library. This gives you control over your public API and prevents accidental exposure of internal utilities.

Exporting components and types

Proper exports are what separate a random collection of files from a real library. You should create an index.ts file in src/lib that serves as your main entry point and re-exports everything your users need.

Consider a button library with multiple variants. Your structure might look like:

typescript
// src/lib/index.ts
export { default as Button } from './Button.svelte';
export { default as IconButton } from './IconButton.svelte';
export { default as ButtonGroup } from './ButtonGroup.svelte';

export type { ButtonProps, ButtonVariant, ButtonSize } from './types';

// src/lib/types.ts
export interface ButtonProps {
  disabled?: boolean;
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'sm' | 'md' | 'lg';
  type?: 'button' | 'submit' | 'reset';
}

export type ButtonVariant = 'primary' | 'secondary' | 'danger';
export type ButtonSize = 'sm' | 'md' | 'lg';

This gives consumers a clean way to import both components and types:

typescript
import { Button, IconButton } from '@yourorg/buttons';
import type { ButtonProps } from '@yourorg/buttons';

TypeScript users get full type safety without extra work. Always export your types so downstream projects can use them for their own components that compose your library.

For organizational libraries, you might split exports across logical groups:

typescript
// src/lib/index.ts
export * from './components';
export * from './hooks';
export * from './utils';

Setting peer dependencies on Svelte

One of the most common mistakes with Svelte libraries is declaring Svelte as a regular dependency rather than a peer dependency. Your library should declare svelte as a peerDependency in package.json, not a direct dependency.

Here's why: if your library declares svelte@5.0.0 as a direct dependency, and a consumer's project uses svelte@4.2.0, npm will install both versions. This bloats the bundle and can cause runtime errors because Svelte's internal APIs differ between major versions.

The correct package.json structure for a Svelte library looks like this:

typescript
{
  "name": "@yourorg/ui-components",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "svelte": "./dist/index.js",
      "default": "./dist/index.js"
    }
  },
  "peerDependencies": {
    "svelte": "^5.0.0"
  },
  "peerDependenciesMeta": {
    "svelte": {
      "optional": false
    }
  },
  "devDependencies": {
    "svelte": "^5.0.0",
    "@sveltejs/package": "^2.0.0"
  }
}

You still install Svelte in devDependencies so you can build and test locally, but consumers bring their own version. This pattern is industry standard and reduces bundle bloat significantly.

Documenting props and snippets

Documentation isn't optional when you're shipping a library. Other developers need to understand what props your components accept, what slots they provide, and what snippets they support. JSDoc comments in your Svelte components improve IDE autocomplete and serve as your API reference.

Here's a well-documented Button component:

typescript
<!-- src/lib/Button.svelte -->
<script lang="ts">
  import type { ButtonProps } from './types';

  /**
   * Visual style of the button
   * @type {'primary' | 'secondary' | 'danger'}
   */
  export let variant: ButtonProps['variant'] = 'primary';

  /**
   * Button size
   * @type {'sm' | 'md' | 'lg'}
   */
  export let size: ButtonProps['size'] = 'md';

  /**
   * Disabled state
   * @type {boolean}
   */
  export let disabled: boolean = false;

  /**
   * HTML button type
   * @type {'button' | 'submit' | 'reset'}
   */
  export let type: ButtonProps['type'] = 'button';

  /**
   * Click handler
   * @type {(e: MouseEvent) => void}
   */
  export let onclick: ((e: MouseEvent) => void) | undefined = undefined;
</script>

<button
  {type}
  {disabled}
  class="btn btn-{variant} btn-{size}"
  on:click={onclick}
>
  <slot />
</button>

<style>
  .btn {
    padding: 0.5rem 1rem;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    font-weight: 500;
    transition: all 200ms ease;
  }

  .btn-primary {
    background: #0066cc;
    color: white;
  }

  .btn-primary:hover:not(:disabled) {
    background: #0052a3;
  }

  .btn-secondary {
    background: #f0f0f0;
    color: #333;
  }

  .btn-danger {
    background: #dc3545;
    color: white;
  }

  .btn-sm {
    padding: 0.25rem 0.75rem;
    font-size: 0.875rem;
  }

  .btn-lg {
    padding: 0.75rem 1.5rem;
    font-size: 1.125rem;
  }

  button:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
</style>

The JSDoc comments appear in VS Code's IntelliSense and other IDEs when developers import your component. Include type information, default values, and what events the component fires.

For more complex components with snippets, document those explicitly:

typescript
<!-- src/lib/Card.svelte -->
<script lang="ts">
  import type { Snippet } from 'svelte';

  /**
   * Content to display in the card header
   * @type {Snippet}
   */
  export let header: Snippet | undefined = undefined;

  /**
   * Main card content
   * @type {Snippet}
   */
  export let children: Snippet;

  /**
   * Footer content
   * @type {Snippet}
   */
  export let footer: Snippet | undefined = undefined;
</script>

<div class="card">
  {#if header}
    <div class="card-header">
      {@render header()}
    </div>
  {/if}
  <div class="card-body">
    {@render children()}
  </div>
  {#if footer}
    <div class="card-footer">
      {@render footer()}
    </div>
  {/if}
</div>

Create a README.md with usage examples, installation instructions, and a table of props for each component. A well-documented library reduces support burden and increases adoption.

Common mistakes

  • Forgetting to build before publishing: Run npm run package locally before npm publish. Your published package should contain the compiled dist/ directory, not source files.
  • Declaring Svelte as a direct dependency: This causes duplicate Svelte installations and bundle bloat. Always use peerDependencies.
  • Over-exporting internals: Every export becomes part of your public API contract. If you export utility functions that change, you're breaking downstream projects. Keep exports minimal and stable.
  • Not versioning properly: Follow semantic versioning. Changing component props is a breaking change that requires a major version bump. Use npm version major|minor|patch.
  • Skipping TypeScript definitions: svelte-package generates .d.ts files automatically, but only if your components have type-safe props. Omitting types hurts downstream TypeScript users.
  • Publishing with demo code: Ensure your package.json has a correct files field to exclude demo apps, test files, and source maps from the published tarball.

Key takeaways

  • Use svelte-package to build libraries with proper ESM, CommonJS, and TypeScript support automatically.
  • Export components and types through a single index.ts entry point to create a clean public API.
  • Declare svelte as a peerDependency, not a direct dependency, to avoid bundle bloat.
  • Document all props, snippets, and events with JSDoc comments so IDE autocomplete works for consumers.
  • Use package.exports to control what can be imported, preventing accidental internal API exposure.
  • Follow semantic versioning strictly—prop changes are breaking changes.
  • Test your library in a separate project before publishing to catch integration issues.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles