Figma Plugin Development with TypeScript: Complete Setup

JP
Jordan Pham
UX/UI Designer & Plugin Developer | 7+ Years Experience

Say you are trying to build a Figma plugin that renames layers based on a pattern you define, and you’ve decided to do it properly in TypeScript instead of copy-pasting JavaScript snippets from old tutorials. You open a terminal, create a folder, and immediately hit the first real question: what actually goes in this project before you write a single line of plugin logic? That question, and the dozen smaller ones that follow it, is what this post walks through — using that renaming plugin as the running example from the first npm init to a build that loads cleanly in Figma’s desktop app.


Starting From an Empty Folder

The plugin idea is simple enough to state in one sentence: select a batch of layers, apply a naming pattern with a numeric suffix, and rename them all in one action. Simple ideas are good teaching material precisely because the setup work isn’t hidden behind business logic. Everything you see in the config files below is scaffolding this specific plugin needs, and every plugin needs something close to it.

Create the folder, initialize a package, and get the dependency list sorted first:

mkdir layer-renamer && cd layer-renamer
npm init -y
npm install --save-dev typescript @figma/plugin-typings

Two things are worth pausing on here. First, @figma/plugin-typings is not optional in any practical sense — it’s the package that gives you type definitions for the entire figma global object, and without it TypeScript has no idea what figma.currentPage or figma.ui even are. Second, TypeScript itself is a dev dependency, not a runtime one, because Figma never sees your .ts files. It only ever loads compiled JavaScript, which means the TypeScript layer exists entirely for your benefit during development.


Writing the Manifest Before Writing Any Code

Before touching main.ts, the plugin needs a manifest.json. This file tells Figma’s desktop app what the plugin is called, where its code lives, and what permissions it needs. For the renamer, it looks like this:

{
  "name": "Layer Renamer",
  "id": "layer-renamer-dev",
  "api": "1.0.0",
  "main": "code.js",
  "editorType": ["figma"]
}

Notice that main points to code.js, not code.ts. This trips up nearly everyone on their first plugin, and it’s worth understanding why rather than just memorizing it: Figma’s runtime is a JavaScript environment with no built-in TypeScript support, so the manifest always points at compiled output. Your TypeScript source file will typically be named something like code.ts, and the compiler’s job is to turn that into the code.js the manifest expects.


Configuring TypeScript for a Sandboxed Runtime

Here’s where the renaming plugin’s setup diverges a little from a typical Node or browser project, and it’s the part most generic TypeScript guides don’t cover because they’re not written with Figma’s sandbox in mind. Figma plugins run in a restricted JavaScript environment — no document, no window, no access to most browser APIs you’d normally reach for. Your tsconfig.json needs to reflect that constraint, not fight against it.

{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["ES2017"],
    "strict": true,
    "typeRoots": ["./node_modules/@figma"],
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*.ts"]
}

A few of these deserve explanation rather than blind copying:

  • lib: ["ES2017"] deliberately excludes the DOM library. Including "DOM" here would let TypeScript compile code that references document or window in the main plugin thread, which will fail silently or loudly at runtime since that code never runs in a browser context. Leaving it out forces the compiler to catch that mistake before you ever open Figma.
  • typeRoots points explicitly at the Figma typings package, so figma.ui.postMessage(...) and similar calls resolve correctly without extra configuration scattered elsewhere.
  • strict: true isn’t strictly required, but for a plugin that manipulates node properties across an arbitrary selection, catching undefined node references at compile time saves a debugging session later. Selections are unpredictable by nature — a user might select a frame, a group, and three text nodes at once — and strict null checks catch the cases where your code assumes a property exists that a given node type doesn’t have.

Splitting Main-Thread Code From the UI

The renaming plugin needs a small interface — an input for the naming pattern, a button to apply it — which means it needs two separate execution contexts: the plugin’s main thread (which touches the Figma document) and an iframe-based UI (which touches HTML). This split confuses newcomers more than anything else in the setup, so it’s worth being precise about it.

The main thread file, src/code.ts, has access to the figma API but no DOM:

figma.showUI(__html__, { width: 240, height: 160 });

figma.ui.onmessage = (msg: { type: string; pattern: string }) => {
  if (msg.type === "rename") {
    const selection = figma.currentPage.selection;
    selection.forEach((node, index) => {
      node.name = msg.pattern.replace("{n}", String(index + 1));
    });
    figma.notify(`Renamed ${selection.length} layers.`);
  }
};

The UI, by contrast, is plain HTML with a <script> block that talks to the main thread through parent.postMessage. It has DOM access but zero access to the figma object directly — every interaction has to go through message passing. This is a deliberate architectural boundary in Figma’s plugin API, not an accident of tooling, and understanding it early prevents a lot of “why can’t I just call figma.currentPage from my button handler” confusion down the line.

The __html__ global in that first line is itself a typing that comes from @figma/plugin-typings — another small reason skipping that package causes immediate compiler errors on otherwise correct code.


Getting the Build Pipeline Right

With source split across src/code.ts and a UI file, you need a build step that compiles the TypeScript and lands the output where manifest.json expects it. For a project this size, the TypeScript compiler alone is enough — no bundler required yet:

{
  "scripts": {
    "build": "tsc",
    "watch": "tsc --watch"
  }
}

Run npm run build and TypeScript writes compiled output into dist/, following the outDir setting from earlier. Adjust the manifest’s main field to point at dist/code.js, or configure outDir to write directly into the project root if you’d rather keep the manifest untouched. Either works; consistency matters more than which specific path you choose.

npm run watch earns its place in almost every plugin project past the prototype stage. Figma’s desktop app doesn’t hot-reload — you still need to trigger a manual re-run through the plugin menu after each change — but a running watcher means you’re never waiting on a manual compile step in between. That small removal of friction adds up meaningfully across a session of iterative testing.


Loading the Plugin and Catching the First Round of Errors

Inside Figma’s desktop app, go to Plugins → Development → Import plugin from manifest, and point it at your manifest.json. Run it against a selection of layers, and one of two things typically happens: it works, or the console shows a type error that never would have surfaced in plain JavaScript.

A common one at this stage: calling .characters on a node without first checking that it’s a TextNode. TypeScript will flag this before runtime, because SceneNode is a union type and .characters only exists on some members of that union. The fix is a type guard:

if (node.type === "TEXT") {
  node.characters = "Updated";
}

This is a small example, but it’s representative of the entire value proposition of doing plugin development in TypeScript rather than JavaScript. Figma’s node model is a large union of types with overlapping but distinct property sets, and a plugin manipulating an arbitrary user selection has to handle that variety correctly. JavaScript will let inconsistent handling through silently, right up until a user selects the one node type your code didn’t anticipate. TypeScript surfaces that gap during development, while it’s cheap to fix, instead of during someone else’s design review.


Where This Setup Starts to Show Its Limits

The configuration above is sufficient for the renaming plugin and for a large share of small-to-medium plugins generally. It stops being sufficient once a project needs to bundle multiple UI dependencies, import npm packages into the UI thread, or manage a more complex build with CSS and assets alongside the script. At that point, reaching for a bundler like esbuild or Webpack — rather than the bare TypeScript compiler — becomes the right call. That’s a setup change worth making deliberately, once the need shows up, rather than something to front-load into a first plugin where it adds complexity without a matching benefit yet.

Setup Checklist for a New TypeScript Plugin

StepFilePurpose
Install typingspackage.jsonGives TypeScript knowledge of the figma global
Declare plugin metadatamanifest.jsonPoints Figma at compiled JS, not source TS
Configure compilertsconfig.jsonExcludes DOM lib, sets Figma typeRoots
Split main thread and UIsrc/code.ts + UI HTMLRespects the sandbox/DOM boundary
Add build scriptspackage.jsonCompiles TS to the path the manifest expects
Test against a real selectionFigma desktopSurfaces union-type errors early

Most of the setup friction in a first TypeScript plugin traces back to one of these six steps being skipped or misconfigured — usually the manifest pointing at the wrong file, or the DOM lib being left in tsconfig.json by habit from browser projects. Work through the table in order on your next plugin, and the parts that felt confusing the first time tend to become close to automatic by the third.

About the Author

Jordan Pham is a UX/UI designer and Figma plugin developer with 7 years of design experience and several published plugins on the Figma Community, used by thousands of designers.