Skip to main content

[create your own]

Community add-ons are currently experimental. The API may change. Don't use them in production yet!

This guide covers how to create, test, and publish community add-ons for sv.

Quick start

The easiest way to create an add-on is by using the addon template:

npx sv create --template addon [path]

The newly created project will have a README.md and CONTRIBUTING.md to guide you along.

Project structure

Typically, an add-on looks like this:

import { 
const transforms: {
    script(cb: (file: {
        ast: Program;
        comments: Comments;
        content: string;
        js: typeof index_d_exports$3;
    }) => void | false, options?: TransformOptions): (content: string) => string;
    svelte(cb: (file: {
        ast: AST.Root;
        content: string;
        svelte: typeof index_d_exports$4;
        js: typeof index_d_exports$3;
    }) => void | false, options?: TransformOptions): (content: string) => string;
    ... 6 more ...;
    text(cb: (file: {
        content: string;
        text: typeof text_d_exports;
    }) => string | false): TransformFn;
}

File transform primitives that know their format.

sv-utils = what to do to content, sv = where and when to do it.

Each transform wraps: parse -> callback({ast/data, utils}) -> generateCode(). The parser choice is baked into the transform type - you can't accidentally parse a vite config as svelte because you never call a parser yourself.

Transforms are curried: call with the callback to get a (content: string) => string function that plugs directly into sv.file().

@example
import { transforms } from '@sveltejs/sv-utils';

// use with sv.file() - curried form plugs in directly
sv.file(files.viteConfig, transforms.script(({ ast, js }) => {
  js.vite.addPlugin(ast, { code: 'kitRoutes()' });
}));

// standalone usage / testing
const result = transforms.script(({ ast, js }) => {
  js.imports.addDefault(ast, { as: 'foo', from: 'foo' });
})(fileContent);
transforms
} from '@sveltejs/sv-utils';
import { function defineAddon<const Id extends string, Args extends OptionDefinition>(config: Addon<Args, Id>): Addon<Args, Id>

The entry point for your addon, It will hold every thing! (options, setup, run, nextSteps, ...)

defineAddon
, function defineAddonOptions(): OptionBuilder<{}>

Options for an addon.

Will be prompted to the user if there are not answered by args when calling the cli.

const options = defineAddonOptions()
  .add('demo', {
	question: `demo? ${color.optional('(a cool one!)')}`
	type: string | boolean | number | select | multiselect,
	default: true,
  })
  .build();

To define by args, you can do

npx sv add <addon>=<option1>:<value1>+<option2>:<value2>
defineAddonOptions
} from 'sv';
export default
defineAddon<"addon-name", {
    who: Question<Record<"who", {
        readonly question: "To whom should the addon say hello?";
        readonly type: "string";
    }>>;
}>(config: Addon<{
    who: Question<Record<"who", {
        readonly question: "To whom should the addon say hello?";
        readonly type: "string";
    }>>;
}, "addon-name">): Addon<{
    who: Question<Record<"who", {
        readonly question: "To whom should the addon say hello?";
        readonly type: "string";
    }>>;
}, "addon-name">

The entry point for your addon, It will hold every thing! (options, setup, run, nextSteps, ...)

defineAddon
({
id: "addon-name"id: 'addon-name', shortDescription?: string | undefinedshortDescription: 'a better description of what your addon does ;)',
options: {
    who: Question<Record<"who", {
        readonly question: "To whom should the addon say hello?";
        readonly type: "string";
    }>>;
}
options
: function defineAddonOptions(): OptionBuilder<{}>

Options for an addon.

Will be prompted to the user if there are not answered by args when calling the cli.

const options = defineAddonOptions()
  .add('demo', {
	question: `demo? ${color.optional('(a cool one!)')}`
	type: string | boolean | number | select | multiselect,
	default: true,
  })
  .build();

To define by args, you can do

npx sv add <addon>=<option1>:<value1>+<option2>:<value2>
defineAddonOptions
()
.
add<"who", Question<Record<"who", {
    readonly question: "To whom should the addon say hello?";
    readonly type: "string";
}>>>(key: "who", question: Question<Record<"who", {
    readonly question: "To whom should the addon say hello?";
    readonly type: "string";
}>>): OptionBuilder<Record<"who", Question<Record<"who", {
    readonly question: "To whom should the addon say hello?";
    readonly type: "string";
}>>>>

This type is a bit complex, but in usage, it's quite simple!

The idea is to add() options one by one, with the key and the question.

  .add('demo', {
	question: 'Do you want to add a demo?',
	type: 'boolean',  // string, number, select, multiselect
	default: true,
	// condition: (o) => o.previousOption === 'ok',
  })
add
('who', {
question: stringquestion: 'To whom should the addon say hello?', type: "string"type: 'string' // boolean | number | select | multiselect }) .
function build(): {
    who: Question<Record<"who", {
        readonly question: "To whom should the addon say hello?";
        readonly type: "string";
    }>>;
}
build
(),
setup?: ((workspace: Workspace & {
    dependsOn: (name: keyof OfficialAddons) => void;
    unsupported: (reason: string) => void;
    runsAfter: (name: keyof OfficialAddons) => void;
}) => MaybePromise<...>) | undefined
setup
: ({ dependsOn: (name: keyof OfficialAddons) => void

On what official addons does this addon depend on?

dependsOn
, isKit: booleanisKit, unsupported: (reason: string) => void

Why is this addon not supported?

unsupported
}) => {
if (!isKit: booleanisKit) unsupported: (reason: string) => void

Why is this addon not supported?

unsupported
('Requires SvelteKit');
dependsOn: (name: keyof OfficialAddons) => void

On what official addons does this addon depend on?

dependsOn
('vitest');
},
run: (workspace: Workspace & {
    options: OptionValues<{
        who: Question<Record<"who", {
            readonly question: "To whom should the addon say hello?";
            readonly type: "string";
        }>>;
    }>;
    sv: SvApi;
    cancel: (reason: string) => void;
}) => MaybePromise<void>
run
: ({ isKit: booleanisKit, cancel: (reason: string) => void

Cancel the addon at any time!

cancel
, sv: SvApisv,
options: OptionValues<{
    who: Question<Record<"who", {
        readonly question: "To whom should the addon say hello?";
        readonly type: "string";
    }>>;
}>

Add-on options

options
,
file: {
    viteConfig: "vite.config.js" | "vite.config.ts";
    svelteConfig: "svelte.config.js" | "svelte.config.ts";
    typeConfig: "jsconfig.json" | "tsconfig.json" | undefined;
    stylesheet: `${string}/layout.css` | "src/app.css";
    package: "package.json";
    gitignore: ".gitignore";
    prettierignore: ".prettierignore";
    prettierrc: ".prettierrc";
    eslintConfig: "eslint.config.js";
    vscodeSettings: ".vscode/settings.json";
    vscodeExtensions: ".vscode/extensions.json";
    getRelative: ({ from, to }: {
        from?: string;
        to: string;
    }) => string;
}
file
, language: "ts" | "js"language,
directory: {
    src: string;
    lib: string;
    kitRoutes: string;
}
directory
}) => {
// Add "Hello [who]!" to the root page sv: SvApisv.file: (path: string, edit: (content: string) => string | false) => void

Edit a file in the workspace. (will create it if it doesn't exist)

Return false from the callback to abort - the original content is returned unchanged.

file
(
directory: {
    src: string;
    lib: string;
    kitRoutes: string;
}
directory
.kitRoutes: stringkitRoutes + '/+page.svelte',
const transforms: {
    script(cb: (file: {
        ast: Program;
        comments: Comments;
        content: string;
        js: typeof index_d_exports$3;
    }) => void | false, options?: TransformOptions): (content: string) => string;
    svelte(cb: (file: {
        ast: AST.Root;
        content: string;
        svelte: typeof index_d_exports$4;
        js: typeof index_d_exports$3;
    }) => void | false, options?: TransformOptions): (content: string) => string;
    ... 6 more ...;
    text(cb: (file: {
        content: string;
        text: typeof text_d_exports;
    }) => string | false): TransformFn;
}

File transform primitives that know their format.

sv-utils = what to do to content, sv = where and when to do it.

Each transform wraps: parse -> callback({ast/data, utils}) -> generateCode(). The parser choice is baked into the transform type - you can't accidentally parse a vite config as svelte because you never call a parser yourself.

Transforms are curried: call with the callback to get a (content: string) => string function that plugs directly into sv.file().

@example
import { transforms } from '@sveltejs/sv-utils';

// use with sv.file() - curried form plugs in directly
sv.file(files.viteConfig, transforms.script(({ ast, js }) => {
  js.vite.addPlugin(ast, { code: 'kitRoutes()' });
}));

// standalone usage / testing
const result = transforms.script(({ ast, js }) => {
  js.imports.addDefault(ast, { as: 'foo', from: 'foo' });
})(fileContent);
transforms
.
function svelte(cb: (file: {
    ast: AST.Root;
    content: string;
    svelte: typeof index_d_exports$4;
    js: typeof index_d_exports$3;
}) => void | false, options?: TransformOptions): (content: string) => string

Transform a Svelte component file.

Return false from the callback to abort - the original content is returned unchanged.

svelte
(({ ast: AST.Rootast, svelte: typeof index_d_exports$4svelte }) => {
svelte: typeof index_d_exports$4svelte.
index_d_exports$4.addFragment(ast: AST.Root, content: string, options?: {
    mode?: "append" | "prepend";
}): void
export index_d_exports$4.addFragment
addFragment
(ast: AST.Rootast, `<p>Hello ${
options: OptionValues<{
    who: Question<Record<"who", {
        readonly question: "To whom should the addon say hello?";
        readonly type: "string";
    }>>;
}>

Add-on options

options
.who: "ERROR: The value for this type is invalid. Ensure that the `default` value exists in `options`."who}!</p>`);
}) ); },
nextSteps?: ((data: Workspace & {
    options: OptionValues<{
        who: Question<Record<"who", {
            readonly question: "To whom should the addon say hello?";
            readonly type: "string";
        }>>;
    }>;
}) => string[]) | undefined
nextSteps
: ({
options: OptionValues<{
    who: Question<Record<"who", {
        readonly question: "To whom should the addon say hello?";
        readonly type: "string";
    }>>;
}>
options
}) => ['enjoy the add-on!']
});

The Svelte CLI is split into two packages with a clear boundary:

  • sv = where and when to do it. It owns paths, workspace detection, dependency tracking, and file I/O. The engine orchestrates add-on execution.
  • @sveltejs/sv-utils = what to do to content. It provides parsers, language tooling, and typed transforms. Everything here is pure - no file system, no workspace awareness.

This separation means transforms are testable without a workspace and composable across add-ons.

Development

You can run your add-on locally using the file: protocol:

cd /path/to/test-project
npx sv add file:../path/to/my-addon

This allows you to iterate quickly without publishing to npm.

The file: protocol also works for custom or private add-ons that you don't intend to publish - for example, to standardize project setup across your team or organization.

The demo-add script automatically builds your add-on before running it.

Testing

The sv/testing module provides utilities for testing your add-on. createSetupTest is a factory that takes your vitest imports and returns a setupTest function. It creates real SvelteKit projects from templates, runs your add-on, and gives you access to the resulting files.

import { import expectexpect } from '@playwright/test';
import module "node:fs"fs from 'node:fs';
import const path: path.PlatformPathpath from 'node:path';
import { 
function createSetupTest(vitest: VitestContext): <Addons extends AddonMap>(addons: Addons, options?: SetupTestOptions<Addons>) => {
    test: vitest.TestAPI<Fixtures>;
    testCases: Array<AddonTestCase<AddonMap>>;
    prepareServer: typeof prepareServer;
}
createSetupTest
} from 'sv/testing';
import * as import vitestvitest from 'vitest'; import import addonaddon from './index.js'; const { const test: vitest.TestAPI<Fixtures>test, const testCases: AddonTestCase<AddonMap>[]testCases } =
function createSetupTest(vitest: VitestContext): <Addons extends AddonMap>(addons: Addons, options?: SetupTestOptions<Addons>) => {
    test: vitest.TestAPI<Fixtures>;
    testCases: Array<AddonTestCase<AddonMap>>;
    prepareServer: typeof prepareServer;
}
createSetupTest
(import vitestvitest)(
{ addon: anyaddon }, {
kinds: {
    type: string;
    options: OptionMap<{
        addon: any;
    }>;
}[]
kinds
: [
{ type: stringtype: 'default',
options: OptionMap<{
    addon: any;
}>
options
: {
'your-addon-name': { who: stringwho: 'World' } } } ],
filter?: ((addonTestCase: AddonTestCase<{
    addon: any;
}>) => boolean) | undefined
filter
: (
testCase: AddonTestCase<{
    addon: any;
}>
testCase
) =>
testCase: AddonTestCase<{
    addon: any;
}>
testCase
.variant: ProjectVariantvariant.String.includes(searchString: string, position?: number): boolean

Returns true if searchString appears as a substring of the result of converting this object to a String, at one or more positions that are greater than or equal to position; otherwise, returns false.

@param
searchString search string
@param
position If position is undefined, 0 is assumed, so as to search all of the String.
includes
('kit'),
browser?: boolean | undefinedbrowser: false } ); const test: vitest.TestAPI<Fixtures>test.
concurrent: ChainableFunction<"concurrent" | "sequential" | "only" | "skip" | "todo" | "fails", TestCollectorCallable<Fixtures>, {
    each: TestEachFunction;
    for: TestForFunction<Fixtures>;
}>
concurrent
.
for: TestForFunction
<AddonTestCase<AddonMap>>(cases: readonly AddonTestCase<AddonMap>[]) => TestForFunctionReturn<AddonTestCase<AddonMap>, vitest.TestContext & Fixtures> (+1 overload)
for
(const testCases: AddonTestCase<AddonMap>[]testCases)('my-addon $kind.type $variant', async (testCase: AddonTestCase<AddonMap>testCase, ctx: vitest.TestContext & Fixturesctx) => {
const const cwd: stringcwd = ctx: vitest.TestContext & Fixturesctx.function cwd(addonTestCase: AddonTestCase<any>): stringcwd(testCase: AddonTestCase<AddonMap>testCase); const const page: stringpage = module "node:fs"fs.
function readFileSync(path: fs.PathOrFileDescriptor, options: {
    encoding: BufferEncoding;
    flag?: string | undefined;
} | BufferEncoding): string (+2 overloads)

Synchronously reads the entire contents of a file.

@param
path A path to a file. If a URL is provided, it must use the file: protocol. If a file descriptor is provided, the underlying file will not be closed automatically.
@param
options Either the encoding for the result, or an object that contains the encoding and an optional flag. If a flag is not provided, it defaults to 'r'.
readFileSync
(const path: path.PlatformPathpath.path.PlatformPath.resolve(...paths: string[]): string

The right-most parameter is considered {to}. Other parameters are considered an array of {from}.

Starting from leftmost {from} parameter, resolves {to} to an absolute path.

If {to} isn't already absolute, {from} arguments are prepended in right to left order, until an absolute path is found. If after using all {from} paths still no absolute path is found, the current working directory is used as well. The resulting path is normalized, and trailing slashes are removed unless the path gets resolved to the root directory.

@param
paths A sequence of paths or path segments.
@throws
TypeError if any of the arguments is not a string.
resolve
(const cwd: stringcwd, 'src/routes/+page.svelte'), 'utf8');
import expectexpect(const page: stringpage).toContain('Hello World!'); });

Your vitest.config.js must include the global setup from sv/testing:

import { function defineConfig(config: UserConfig): UserConfig (+4 overloads)defineConfig } from 'vitest/config';

export default function defineConfig(config: UserConfig): UserConfig (+4 overloads)defineConfig({
	UserConfig.test?: InlineConfig | undefined

Options for Vitest

test
: {
InlineConfig.include?: string[] | undefined

A list of glob patterns that match your test files.

@default
['**/*.{test,spec}.?(c|m)[jt]s?(x)']
include
: ['tests/**/*.test.{js,ts}'],
InlineConfig.globalSetup?: string | string[] | undefined

Path to global setup files

globalSetup
: ['tests/setup/global.js']
} });

And the global test setup script tests/setup/global.js:

import { function fileURLToPath(url: string | URL, options?: FileUrlToPathOptions): string

This function ensures the correct decodings of percent-encoded characters as well as ensuring a cross-platform valid absolute path string.

import { fileURLToPath } from 'node:url';

const __filename = fileURLToPath(import.meta.url);

new URL('file:///C:/path/').pathname;      // Incorrect: /C:/path/
fileURLToPath('file:///C:/path/');         // Correct:   C:\path\ (Windows)

new URL('file://nas/foo.txt').pathname;    // Incorrect: /foo.txt
fileURLToPath('file://nas/foo.txt');       // Correct:   \\nas\foo.txt (Windows)

new URL('file:///你好.txt').pathname;      // Incorrect: /%E4%BD%A0%E5%A5%BD.txt
fileURLToPath('file:///你好.txt');         // Correct:   /你好.txt (POSIX)

new URL('file:///hello world').pathname;   // Incorrect: /hello%20world
fileURLToPath('file:///hello world');      // Correct:   /hello world (POSIX)
@since
v10.12.0
@param
url The file URL string or URL object to convert to a path.
@return
The fully-resolved platform-specific Node.js file path.
fileURLToPath
} from 'node:url';
import {
function setupGlobal({ TEST_DIR, pre, post }: {
    TEST_DIR: string;
    pre?: () => Promise<void>;
    post?: () => Promise<void>;
}): ({ provide }: TestProject) => Promise<() => Promise<void>>
setupGlobal
} from 'sv/testing';
const const TEST_DIR: stringTEST_DIR = function fileURLToPath(url: string | URL, options?: FileUrlToPathOptions): string

This function ensures the correct decodings of percent-encoded characters as well as ensuring a cross-platform valid absolute path string.

import { fileURLToPath } from 'node:url';

const __filename = fileURLToPath(import.meta.url);

new URL('file:///C:/path/').pathname;      // Incorrect: /C:/path/
fileURLToPath('file:///C:/path/');         // Correct:   C:\path\ (Windows)

new URL('file://nas/foo.txt').pathname;    // Incorrect: /foo.txt
fileURLToPath('file://nas/foo.txt');       // Correct:   \\nas\foo.txt (Windows)

new URL('file:///你好.txt').pathname;      // Incorrect: /%E4%BD%A0%E5%A5%BD.txt
fileURLToPath('file:///你好.txt');         // Correct:   /你好.txt (POSIX)

new URL('file:///hello world').pathname;   // Incorrect: /hello%20world
fileURLToPath('file:///hello world');      // Correct:   /hello world (POSIX)
@since
v10.12.0
@param
url The file URL string or URL object to convert to a path.
@return
The fully-resolved platform-specific Node.js file path.
fileURLToPath
(new var URL: new (url: string | URL, base?: string | URL) => URL

The URL interface is used to parse, construct, normalize, and encode URL.

MDN Reference

URL class is a global reference for import { URL } from 'node:url' https://nodejs.org/api/url.html#the-whatwg-url-api

@since
v10.0.0
URL
('../../.test-output/', import.meta.ImportMeta.url: string

The absolute file: URL of the module.

url
));
export default
function setupGlobal({ TEST_DIR, pre, post }: {
    TEST_DIR: string;
    pre?: () => Promise<void>;
    post?: () => Promise<void>;
}): ({ provide }: TestProject) => Promise<() => Promise<void>>
setupGlobal
({ type TEST_DIR: stringTEST_DIR });

Publishing

Bundling

Community add-ons are bundled with tsdown into a single file. Everything is bundled except sv. (It is a peer dependency provided at runtime.)

package.json

Your add-on must have sv as a peer dependency and no dependencies in package.json:

{
	"name": "@my-org/sv",
	"version": "1.0.0",
	"type": "module",
	// bundled entrypoint (tsdown outputs .mjs for ESM)
	"exports": {
		".": { "default": "./dist/index.mjs" }
	},
	"publishConfig": {
		"access": "public"
	},
	// cannot have dependencies
	"dependencies": {},
	"peerDependencies": {
		// minimum version required to run by this add-on
		"sv": "^0.13.0"
	},
	// Add the "sv-add" keyword so users can discover your add-on
	"keywords": ["sv-add", "svelte", "sveltekit"]
}

Naming convention

packages names

If you name your package @my-org/sv, users can install it by typing just the org handle:

npx sv add @my-org

It's also possible to publish like @my-org/core, just users will need to type the full package name.

npx sv add @my-org/core

Unscoped packages are not supported yet

export options

sv first tries to import your-package/sv, then falls back to the default export. This means you have two options:

  1. Default export (for dedicated add-on packages):

    {
    	"exports": {
    		".": "./src/index.js"
    	}
    }
  2. ./sv export (for packages that also export other functionality):

    {
    	"exports": {
    		".": "./src/main.js",
    		"./sv": "./src/addon.js"
    	}
    }

Publish to npm

npm login
npm publish

prepublishOnly automatically runs the build before publishing.

Next steps

You can optionally display guidance in the console after your add-on runs:

import { 
const color: {
    addon: (str: string) => string;
    command: (str: string) => string;
    env: (str: string) => string;
    path: (str: string) => string;
    route: (str: string) => string;
    website: (str: string) => string;
    optional: (str: string) => string;
    dim: (str: string) => string;
    success: (str: string) => string;
    warning: (str: string) => string;
    error: (str: string) => string;
    hidden: (str: string) => string;
}
color
} from '@sveltejs/sv-utils';
export default defineAddon({ // ...
nextSteps: ({ options }: {
    options: any;
}) => string[]
nextSteps
: ({ options: anyoptions }) => [
`Run ${
const color: {
    addon: (str: string) => string;
    command: (str: string) => string;
    env: (str: string) => string;
    path: (str: string) => string;
    route: (str: string) => string;
    website: (str: string) => string;
    optional: (str: string) => string;
    dim: (str: string) => string;
    success: (str: string) => string;
    warning: (str: string) => string;
    error: (str: string) => string;
    hidden: (str: string) => string;
}
color
.command: (str: string) => stringcommand('npm run dev')} to start developing`,
`Check out the docs at https://...` ] });

Version compatibility

Your add-on should specify a minimum sv version in peerDependencies. Your users will get a compatibility warning if their sv version has a different major version than what was specified.

Examples

See the official add-on source code for some real world examples.

Edit this page on GitHub llms.txt

previous next