vue-to-tsx
v0.4.3
Published
Convert Vue Single File Components (.vue) to Vue TSX (.tsx + .css)
Maintainers
Readme
vue-to-tsx
Convert Vue Single File Components (.vue) to Vue TSX (.tsx + .css).
This is not a React migration tool. The output is Vue TSX -- it stays within the Vue ecosystem, using defineComponent, Vue's JSX transform, and plain CSS imports.
Why?
Vue's Single File Component format is a well-designed authoring experience. But it comes with a cost: .vue files only work with custom tooling. Your editor needs a Vue-specific extension. Your bundler needs a Vue plugin. Your linter, your test runner, your CI -- everything in the chain needs to know what a .vue file is.
TSX changes that. A .tsx file is just TypeScript. It works everywhere TypeScript works -- no plugins, no extensions, no custom language servers. You get:
- Native TypeScript support -- full type checking, refactoring, and go-to-definition without Volar or any editor extension
- Standard tooling -- any bundler, linter, test runner, or CI pipeline that supports TypeScript works out of the box
- All of Vue's power --
defineComponent,ref,computed,watch, slots, emits, provide/inject -- it all works in TSX - Full Nuxt compatibility -- Nuxt auto-imports, composables, and middleware work identically in
.tsxfiles - Better composition -- components are just functions returning JSX, making it natural to compose, split, and reuse rendering logic
- No lock-in -- your code is portable TypeScript, not a framework-specific file format
vue-to-tsx automates the conversion so you can migrate gradually, file by file, without rewriting anything by hand.
Features
- Template to JSX conversion (v-if/v-for/v-show/v-model, slots, events)
<script setup>todefineComponentwith full macro support (defineProps, defineEmits, defineSlots, defineExpose, defineOptions, defineModel)- Type-based
defineEmitsconverted to runtimeemitsoption (call signature and Vue 3.3+ shorthand forms, including kebab-case event names) - Automatic
.valueunwrapping forref/computedidentifiers in JSX expressions (string-literal-aware -- won't corrupt'statement'or'default'inside quotes). Also detectsuse*composable return values as refs (e.g.,useLocalStorage,useDark) - Automatic
props.prefixing for prop identifiers in template expressions (also string-literal-aware) - Vue built-in components (
Teleport,KeepAlive,Transition,TransitionGroup,Suspense) auto-imported fromvue - Auto-imports Vue APIs used in runtime props/emits (
PropType,ref,computed, etc.) v-foruses a runtime helper that supports arrays, objects, and numbers (matching Vue's runtime behavior)- Static
classand dynamic:classmerged into a single attribute (no duplicate class props) .vueimport paths automatically stripped (e.g.,import Foo from './Foo.vue'becomes'./Foo')- Scoped CSS extracted to plain
.cssfiles (side-effect import) - Handles complex patterns: v-if/v-else-if/v-else chains, dynamic components, named/scoped slots
- Optional LLM fallback for patterns that can't be converted deterministically (Anthropic and OpenAI)
- CLI for batch conversion and library API for programmatic use
Installation
# bun
bun add -d vue-to-tsx
# npm
npm install -D vue-to-tsx
# pnpm
pnpm add -D vue-to-tsxCLI usage
# Convert a single file
vue-to-tsx src/components/MyComponent.vue
# Convert a directory recursively
vue-to-tsx src/components/
# Convert and delete original .vue files (in-place replacement)
vue-to-tsx src/components/ --delete
# Convert with LLM fallback for complex patterns
vue-to-tsx src/components/ --llm
# Write output to a specific directory
vue-to-tsx src/components/ --out-dir converted/
# Preview what would happen without writing anything
vue-to-tsx src/components/ --dry-run --deleteLibrary API
import { convert } from 'vue-to-tsx';
const source = `
<template>
<div class="container">
<h1>{{ title }}</h1>
<button @click="handleClick">Click me</button>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{ title: string }>();
const emit = defineEmits<{ click: [] }>();
function handleClick() {
emit('click');
}
</script>
<style scoped>
.container {
padding: 16px;
}
</style>
`;
const result = await convert(source, {
componentName: 'MyComponent',
});
console.log(result.tsx); // The generated .tsx file
console.log(result.css); // The generated .css file (or null)
console.log(result.warnings); // Any conversion warnings
console.log(result.fallbacks); // Items that need manual reviewHow it works
Template to JSX -- The Vue template AST (from
@vue/compiler-sfc) is walked and converted to JSX. Directives likev-ifbecome ternary expressions,v-foruses a runtime helper (_renderList) that handles arrays, objects, and numbers,@clickbecomesonClick, etc.Script setup to defineComponent --
<script setup>macros (defineProps,defineEmits,defineSlots, etc.) are extracted and rewritten into adefineComponentcall with propersetup()function.Scoped CSS to plain CSS --
<style scoped>blocks are extracted to plain.cssfiles and imported as side-effect imports (import './Component.css'). Vue-specific pseudo-selectors (:deep,:slotted,:global) are stripped.LLM fallback -- When a template pattern can't be converted deterministically (e.g., complex custom directives), it's marked with a fallback comment. With
--llmenabled, these are sent to an LLM for resolution.
Key differences between Vue SFC and TSX
Vue SFC templates are forgiving by design -- they silently handle things that would be errors in plain TypeScript. This convenience has a cost: it hides real bugs and makes code harder to reason about. TSX is explicit. The table below shows what vue-to-tsx does to bridge the gap, and why the TSX version is often the better one.
| Vue SFC (implicit) | Generated TSX (explicit) | Why it matters |
|---|---|---|
| {{ count }} auto-unwraps refs | {count.value} | In TSX, refs are regular objects. You must access .value explicitly. This catches bugs where you forget to create a ref in the first place. |
| {{ title }} auto-exposes props | {props.title} | Vue templates magically expose prop names as local variables. TSX makes the data source clear -- you always know when something comes from props. |
| v-for="item in obj" works on arrays, objects, and numbers | _renderList(obj, (item) => ...) | Vue's v-for silently iterates objects via Object.keys() and generates ranges from numbers. TSX has no such magic -- _renderList is a runtime helper that matches Vue's behavior exactly. |
| const x = useLocalStorage(...) auto-unwrapped in template | x.value in JSX | Vue templates auto-unwrap any ref, but TSX can't. The converter detects use* composable return values (following Vue's naming convention) and adds .value. |
| <Teleport>, <KeepAlive>, etc. resolve magically | import { Teleport } from 'vue' | Vue's compiler knows about built-in components. In TSX, they're just identifiers -- they need explicit imports like anything else. |
| @click.prevent="handler" | onClick={withModifiers(handler, ['prevent'])} | Event modifiers are template-only syntax. TSX uses Vue's withModifiers runtime helper. |
| $attrs, $slots, $emit | attrs, slots, emit from setup() context | Template globals don't exist in TSX. They come from the setup function's second argument. |
| <script setup> macros (defineProps, defineEmits) | defineComponent({ props: {...}, emits: [...] }) | Compiler macros are template-only magic. TSX uses standard defineComponent options. Type-based defineEmits is converted to a runtime emits array. |
| import Foo from './Foo.vue' | import Foo from './Foo' | .vue extensions are stripped since you're importing .tsx files now. |
| <style scoped> | Plain .css with side-effect import | Scoped styles use data attributes that only work with Vue's compiler. The CSS is extracted as a plain file and imported directly (import './Component.css'). |
| class="foo" :class="{ active: x }" (two attributes) | class={['foo', { active: x }]} (merged) | Vue templates merge multiple class attributes at runtime. JSX doesn't -- duplicate props overwrite each other. The converter merges them into one. |
Output formatting
The converter produces syntactically valid TSX but does not format or prettify it. Run your project's formatter on the output files to match your codebase style:
# Prettier
bunx prettier --write "src/**/*.tsx"
# Biome
bunx biome format --write "src/**/*.tsx"
# dprint
bunx dprint fmt "src/**/*.tsx"LLM-powered fallback
The deterministic converter handles the vast majority of Vue patterns -- v-if/v-for/v-show, v-model, slots, events, macros, CSS extraction, and more. But roughly 5% of real-world Vue code uses patterns that have no single correct JSX translation. For these, vue-to-tsx offers an AI-powered fallback that understands Vue semantics and produces idiomatic JSX.
What triggers the fallback
- Custom directives --
v-focus,v-tooltip,v-click-outside, and any app-specific directives v-memo-- performance hint with no direct JSX equivalent- Complex slot forwarding -- dynamically passing through
$slotsto child components - Dynamic components with complex
:is--<component :is="someCondition ? CompA : CompB" />with non-trivial expressions
Without --llm
These patterns are marked with a // TODO: vue-to-tsx comment so you can resolve them manually:
{/* TODO: vue-to-tsx - Custom directive "v-tooltip" cannot be converted deterministically */}
{/* Original: <span v-tooltip="helpText">Hover me</span> */}With --llm
The same pattern is intelligently converted, producing a working JSX equivalent:
<Tooltip text={helpText.value}>
<span>Hover me</span>
</Tooltip>All fallback items in a file are batched into a single API call, so even a file with multiple custom directives only makes one request. This keeps costs low and latency minimal.
Setup
Set an API key for your preferred provider:
# Anthropic (default if both are set)
export ANTHROPIC_API_KEY=sk-ant-...
# OpenAI
export OPENAI_API_KEY=sk-...The provider is auto-detected from whichever API key is set. If both are set, Anthropic is preferred. You can override this with VUE_TO_TSX_LLM_PROVIDER:
export VUE_TO_TSX_LLM_PROVIDER=openai # force OpenAI even if ANTHROPIC_API_KEY is setThen pass --llm to the CLI:
vue-to-tsx src/components/ --llmModel override
Default models: claude-sonnet-4-6 (Anthropic), gpt-5.2-codex (OpenAI). Override via CLI flag or env var:
# CLI flag
vue-to-tsx src/components/ --llm --llm-model gpt-4o-mini
# Environment variable
export VUE_TO_TSX_LLM_MODEL=claude-haiku-4-5-20251001Context-aware healing
The LLM receives the full original Vue SFC and the generated TSX so far, giving it complete context to produce accurate replacements. Responses are validated to reject any output that still contains Vue template syntax (v-if, v-for, @click, etc.) — invalid replacements are discarded and the TODO fallback comment is preserved.
Programmatic usage
const result = await convert(source, {
componentName: 'MyComponent',
llm: true,
llmModel: 'claude-sonnet-4-6',
});Options
The convert() function accepts an options object:
interface ConvertOptions {
componentName?: string; // Component name (derived from filename if not provided)
llm?: boolean; // Enable LLM fallback (default: false)
llmModel?: string; // LLM model to use (auto-detected from provider)
}| Option | CLI flag | Default | Description |
|--------|----------|---------|-------------|
| componentName | (from filename) | PascalCase of filename | Name used in defineComponent |
| llm | --llm | false | Enable AI-powered fallback for unconvertible patterns |
| llmModel | --llm-model | Auto (provider-dependent) | LLM model ID for fallback resolution |
Contributing
See CONTRIBUTING.md.
