Building a Framer plugin
A Framer plugin is a React app that runs in an iframe inside the Framer desktop app, built on the framer-plugin npm package (@framer/plugin). Source lives in integrations/framer-plugin/. It is a thin frontend over your product's SDK or public REST API — the SDK gives you the canvas/selection + plugin window; you supply the logic. Read this before the first file; the command-level playbook is the companion scripts/framer-plugin/README.md.
The headline difference from other marketplaces: publishing is near-instant, with light or no review. No days-long review queue. That makes the build traps — not the submission — the whole game.
The trap that wastes a day: the dev plugin needs local HTTPS
Framer loads a development plugin from a URL — and refuses plain http://localhost. The Vite dev server must serve HTTPS via a local certificate (vite-plugin-mkcert), and you must open that https://localhost:5173 URL in a browser once and accept the certificate, or Framer shows a blank/failed panel with no useful error.
Rule: before debugging "my plugin is a white box," confirm the dev URL loads in a browser without a cert warning. Nothing in the code or the build reveals this — only opening the URL does.
The other traps that each cost a round-trip
framer.showUI()before render, at module top level. Callframer.showUI({ position: "top right", width, height, resizable: true })once, beforecreateRoot(...).render(...). Forget it (or set no width/height) and the plugin window never sizes — a blank panel. Do not call it inside a component oruseEffect: under React StrictMode that double-fires. Alsoimport "@framer/plugin/framer.css"for the native look.- The selection is SDK-read, not DOM-read. The iframe cannot touch the canvas DOM. The selected image comes from
framer.getImage()(Promise ofImageAsset | null) andframer.subscribeToImage(cb)for live updates; bytes come fromasset.getData()({ bytes, mimeType }). Await all of it — there is no synchronous getter. Subscribe, don't poll: a post composed before the user re-selects goes out with the stale image. framer.jsonis the manifest and must sit at the zip root. Fields:id(short plugin id),name,modes(["canvas"]for canvas plugins),icon(root-absolute path like/icon.svg, file lives inpublic/).vite-plugin-framercopies it intodist/on build. When you pack, zip the contents ofdist/(zip -r out.zip .from insidedist/) — zipping the folder puts everything one level down and the upload/plugin-load fails.- Plugin data is per project file.
framer.setPluginData(key, value)/framer.getPluginData(key)persist (both async!) in the Framer project, not globally — a user re-pastes the API key in every project. Don't assume a key set once is set everywhere; alwaysgetPluginDataon load and gate the UI on it, andsetPluginData(key, null)on disconnect. - External fetch is NOT allow-listed like Canva. The iframe can call your API directly with
fetch(or your product's SDK) — no portal domain list to forget. Keep the API key server-scoped (a team API key as a Bearer token) and all business logic server-side; the plugin is a thin client. - Dev plugins run in the desktop app. The development-plugin flow (Plugins menu → Developer Tools) is a desktop-app feature; plan the demo/parity pass there, not in the browser version.
The manifest: framer.json
{
"id": "<short plugin id>",
"name": "<Plugin Name>",
"modes": ["canvas"],
"icon": "/icon.svg"
}
Four fields, all load-bearing. modes: ["canvas"] is what makes framer.getImage() / subscribeToImage() available. The icon path is root-absolute; the file itself lives in public/ so Vite copies it into dist/ (SVG is fine). There is no separate permissions block — access to the selection comes with the mode, and network access is unrestricted.
Auth: paste the team API key, store it per project
The standard pattern — and the one to build first, since everything else gates on it:
- On load,
await framer.getPluginData("apiKey"). No key → render the auth gate (a single password-style input + Connect button) instead of the main UI. - On connect, validate the key with one cheap API call, then
await framer.setPluginData("apiKey", key). - On disconnect,
await framer.setPluginData("apiKey", null).
The key never leaves the project except as the Authorization: Bearer header on API requests. Because storage is per project file, a returning user in a new project sees the auth gate again — that is expected, not a bug.
Build → pack, the command sequence
cd integrations/framer-plugin
npm install
npm run dev # vite + mkcert; prints https://localhost:5173
# ONE TIME: open that URL in a browser, accept the cert
npm run build # tsc && vite build → dist/ (framer.json + icon copied in)
npm run pack # build + zip the CONTENTS of dist/ → <name>.zip
Then, in the Framer desktop app: Plugins menu → enable Developer Tools → open the development plugin for the dev URL. Paste the team API key and connect.
Structural traps
- Keep the package isolated.
integrations/framer-plugin/with its own lockfile and an empty local PostCSS config, so monorepo Tailwind/build tooling doesn't leak into the plugin bundle. - Stack: Vite + React +
vite-plugin-framer+vite-plugin-mkcert. UI is plain React —@framer/plugin/framer.csssupplies native styling; no separate UI-kit dependency. showUIsizing is your layout contract. Pickwidth/height(plusminWidth/minHeightwhenresizable: true) to fit the real UI; the iframe does not auto-grow.- Keep hard-coded API paths current — a product API rename 404s every call silently; the plugin has no server to log it.
Submission — Framer Community / Marketplace
Submittable: portal-review, free (no publish CLI — you upload a zip in the Marketplace dashboard).
npm run pack→plugin.zipwithframer.jsonat its root (zip the CONTENTS ofdist/).- Go to the Marketplace dashboard
framer.com/marketplace/dashboard/plugins/→ New Plugin → upload the zip → fill the form (name, description, icon, screenshots, pricing model if any) → submit. - Review is manual and curated, in two phases: an initial check (
7 days, verify) then a design review (14 days, verify). Statuses track in the dashboard: In Review → Needs Changes / Published / Rejected; the outcome comes by email. Common rejects: unclear functionality, poor performance, confusing UI, no light/dark-mode support. - Listing copy must explain how a fresh user authenticates (paste-the-API-key flow is fine — auth is self-service in the plugin, no reviewer account needed). Exact icon/screenshot dimensions are set in the dashboard form (verify at submit).
(The Framer Community plugins category, framer.com/community, is a separate, near-instant post — useful for early distribution, but the curated Marketplace is the reviewed channel.)
Silent-failure gotchas: framer.json not at the zip root (folder zipped instead of contents); forgetting the cert-accept step when demoing the dev plugin; window blank because showUI ran after render or inside an effect; a stale selection image because you read once instead of subscribing; assuming a stored key survives across projects.
Parity checklist (prove in the Framer desktop app before publishing)
dev plugin loads over local HTTPS · window sizes correctly · read the current selection image live (re-select updates it) · upload those bytes to your API · create/schedule the downstream action · surface success/error in the plugin UI · authenticate from a fresh project with no stored key · disconnect clears the stored key.
Related skills
canva-app— the same "design → export → do something" shape on Canva; that one DOES allow-list fetch origins and has a real review queue.figma-plugin— same iframe-in-a-design-tool pattern; different SDK + a manifest.json.browser-extension— another sandboxed surface; the "check the load path before the code" lesson rhymes.connector-directory-submission— the cross-marketplace submission router.