createApp: An Instance You Configure Before a Single Node Renders
Open src/main.ts in a fresh create-vue project and you get roughly six lines. They look like boilerplate until something goes wrong: a directive that "isn't registered" in one route but works in another, an error handler that never fires, a store that throws about no active Pinia. Every one of those traces back to what those six lines do — and, more importantly, the order they do it in.
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './assets/main.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
createApp(App) does not render anything. It builds an application instance — a shell that owns one root component and holds all global configuration for the app. Between createApp and mount you have an unconfigured, unrendered object, and the entire window between them is where configuration belongs, because the first render happens at mount and any global a component's setup() needs must already exist by then.
Everything you register lives on that instance, and every registration API takes the same shape:
| API | Registers | Typical use |
|---|---|---|
app.use(plugin) |
an install function or { install(app, options) } object |
Pinia, vue-router, i18n |
app.component('Name', Comp) |
a global component | an icon set used in every view |
app.directive('focus', vFocus) |
a global directive | v-focus without per-file imports |
app.provide(key, value) |
a root-level injectable | an API client, a feature flag map |
app.mixin(obj) |
options merged into every component | a shared created hook — use sparingly |
app.config.* |
global behavior knobs | errorHandler, globalProperties, isCustomElement |
The one that earns its keep in production is the error handler, set before mount so it catches failures from the very first render:
app.config.errorHandler = (err, instance, info) => {
console.error('render error', err, info)
reportToTelemetry(err)
}
With it in place, an exception thrown during a component render or an event listener becomes a report instead of a silent broken UI. Pair it with app.config.warnHandler if you want to route development warnings into your own logger.
Two properties of this instance matter more than the API list. First, the configuration is app-scoped, not global mutable state. app.component('Icon', ...) affects this application only — which is what makes micro-frontends and test harnesses sane: mount a small Vue widget inside a page that already runs React, or mount a component in a unit test with its own createApp, and nothing leaks between them. Second, the app lifecycle has a matching teardown: app.unmount() tears down the rendered tree, runs unmount hooks, and leaves the container empty. A page that mounts and destroys a widget repeatedly must call unmount, or every cycle leaves a live instance with listeners and a tick queue behind it.
What does not belong in this window: anything that depends on rendered DOM. Measuring elements, attaching ResizeObservers, waiting on route navigation — those go in onMounted inside components. The entry file's job is wiring; components do work.
A detail that surprises people: plugins registered after mount are not retroactively applied to components that already rendered. app.use(router) after mount('#app') means the root component's setup() already ran without a router to inject — you get a warning and a runtime failure, not a late-binding save. The createApp → use → use → mount sequence in the generated main.ts is not a style choice; it is the dependency order of the framework itself. In Single-File Components you will see what the root component App.vue actually contains.
What mount('#app') Actually Does to the Element You Hand It
mount takes either a CSS selector or a real element, finds the container, and attaches the application to it. The behavior at that moment has a few sharp edges worth knowing exactly, because they explain the classic "my markup disappeared" and "my listeners do nothing" bugs.
<!-- index.html -->
<body>
<div id="app">
<p>Static fallback shown before JavaScript loads.</p>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
When app.mount('#app') runs:
- Vue looks up the selector. If nothing matches, mounting fails with
[Vue warn]: Failed to mount app: mount container selector "#app" did not return an element.The app never renders — a renamedindex.htmlid with an unchangedmain.tsproduces exactly this. - Vue takes the container element's
innerHTMLand replaces it with the rendered output of the root component. The container itself stays in the DOM with its id intact — you can confirm the identity ofdocument.querySelector('#app')before and after mounting — but everything inside it is discarded. That is why the static fallback paragraph vanishes: it existed for the no-JavaScript case (and for search-engine crawlers, in the days before SSR), and Vue claims the space the moment it boots. - The root component instance is created, its
setup()runs, its template compiles to a render function, and the tree is written to the container synchronously. Whenmountreturns,document.getElementById('app')already contains the full initial DOM — you do not wait for a tick to see it. mountreturns the root component's public instance, thethisyou would have had under the Options API. It is rarely needed, but it is handy for tests that want to poke the root directly.
One container, one app. Nothing stops you from calling createApp(...).mount('#app') a second time while the first app is live. The second mount clears the container and writes its own tree — the first app's DOM disappears while its instances, watchers, and event listeners stay alive in memory, still bound to nodes that are no longer in the document. The symptom is a component that renders but never reacts: clicks go to the new tree, the old app silently updates detached nodes, and memory climbs with each cycle. If you are mounting widgets repeatedly (a dashboard that opens a panel, a test file that mounts per case), call app.unmount() on the previous instance first.
The container's own styles apply to the rendered content, which matters for full-page apps: put margin: 0 and your CSS reset on html, body or on #app — the component tree inherits whatever the container sits inside. Components do not create style boundaries; that is the job of the scoped attribute you will meet in the next topic.
Finally, index.html is itself part of Vite's contract, not a static artifact: its <script type="module" src="/src/main.ts"> is the import graph's root, the paths in it are resolved by Vite, and it is the only HTML file the dev server needs. Move it into public/ and Vite stops processing it entirely — a common way to break the entry while everything else still looks right.
Next: Single-File Components, where App.vue and everything it imports get assembled.
Why `app.use(router)` Must Come Before `mount` — And What Breaks When It Doesn't
Plugins are functions with an install method, and app.use is the only sanctioned way to install one. Under the hood app.use(plugin, options) calls plugin.install(app, options) (or the function itself), lets it register provides, components, and directives on the app, and records the plugin so a second use is a no-op. The whole contract fits in a file you can write yourself:
// plugins/analytics.ts
export const analytics = {
install(app, options) {
app.provide('analytics', options)
app.config.globalProperties.$track = (event) => options.track(event)
console.log('analytics installed for', options.appId)
}
}
// main.ts
app.use(analytics, { appId: 'checkout-web', track: sendEvent })
The ordering rule follows from how components boot. app.mount() is what creates the root component and runs its setup(). setup() may call useRouter(), useStore(), or inject('analytics') — all of which read values that a plugin must have provided. Install after mount and the reads happen first: vue-router's useRouter() returns undefined behind a useRouter() may only be used during setup-style warning, and Pinia throws getActivePinia was called with no active Pinia. Did you forget to install pinia? when a store is set up outside an installed pinia. Neither error points at main.ts, which is why they cost people an afternoon.
The generated entry shows the correct sequence — create the pinia first because the router's guards may call stores, then the router because route components will use both:
const app = createApp(App)
app.use(createPinia()) // pinia before router: guards can use stores
app.use(router) // provides routerKey for every component's setup
app.mount('#app') // first render happens here, with both available
A related edge: module-scope store usage. If stores/cart.ts calls useCartStore() at the top level instead of inside a function, it executes during import — before app.use(createPinia()) even runs — and fails for the same reason. Stores are meant to be called inside setup() or a method; deferring with const store = useCartStore() inside a component's setup is the fix.
When initial navigation must finish before the first paint — async route components, route guards that prefetch data — wait on the router explicitly:
app.use(router)
await router.isReady() // initial navigation resolved
app.mount('#app') // first render already has the matched route
Without isReady(), the app mounts immediately and router-view renders whatever state the navigation happens to be in, which is usually correct but blank for a beat when guards are slow.
Finally, plugin scope defines your testing strategy. Because provides, global components, and app.config all hang off the application instance, a unit test mounts its own app with exactly the plugins under test:
const app = createApp(Wrapper)
app.use(createPinia())
app.mount(container)
No hidden globals, no cleanup between tests beyond app.unmount(). That isolation — one app instance equals one configuration universe — is the design paying off. Continue to Single-File Components to build the root component this entry file mounts.
One last property of plugins worth knowing before you write your own: installation is idempotent and app-scoped. app.use(pinia) twice on the same app is a no-op — the install function runs once — but two different apps each run it, which is why a plugin must never cache per-app state in module scope. The factory pattern keeps multi-tenant cases clean: createAnalytics(options) returns an installable object that closes over its options, so two apps on one page can each install their own instance with different configuration, and tests install fresh copies without cross-test bleed.
Quiz
1. index.html contains `<div id="app"><p>Static fallback</p></div>` and the root component renders `<main>Hello</main>`. What does the DOM look like after `app.mount('#app')`?
2. A developer moves `app.use(createPinia())` to the line after `app.mount('#app')`. What is the most likely first symptom?
3. A dashboard mounts the same widget with `createApp(Widget).mount('#slot')` every time a panel opens, and reports clicks no longer work after the third open. What is happening?
4. You want a `$track(event)` helper callable from any component's template without importing it, scoped to this application only. Where do you register it?
Flashcards
Question
What does `createApp(Component)` return, and when does rendering begin?
Click to reveal answer
Answer
It returns an application instance that owns one root component and holds app-scoped configuration (plugins, global components/directives, provides, app.config). Nothing renders until `app.mount(container)` is called — everything between createApp and mount is the configuration window.
Question
What happens to the mount container's existing content when `mount('#app')` runs?
Click to reveal answer
Answer
The container element itself stays in the DOM (with its id), but its innerHTML is removed and replaced by the root component's rendered tree, synchronously. Static fallback markup inside #app exists only until Vue boots. If the selector matches nothing, mounting fails with 'did not return an element'.
Question
Why must `app.use(pinia)` and `app.use(router)` appear before `app.mount()`?
Click to reveal answer
Answer
mount triggers the first render, and component setup() runs then — reading router/store/inject values that plugins must have provided already. Installing a plugin afterwards never re-runs those setup functions, so useRouter() yields undefined or Pinia throws 'getActivePinia was called with no active Pinia'.
Question
What does `app.mount()` return, and what does `app.unmount()` do?
Click to reveal answer
Answer
mount returns the root component's public instance (the object `this` referred to under the Options API). unmount tears down the rendered tree, fires unmount hooks, and empties the container — required before mounting again in the same container or the old instance leaks with listeners on detached nodes.
Question
Why is each application instance an isolated universe for plugins and configuration?
Click to reveal answer
Answer
Plugins, app.component/directive/mixin registrations, app.provide, and app.config all hang off that instance, never off a global. Two createApp calls therefore share nothing — which enables mounting a Vue widget inside a React page, per-test apps in unit tests, and safe unmount/remount cycles.
Revision Notes
Key Takeaways
- 1. createApp builds an unrendered application instance; the gap between createApp and mount is where all global wiring belongs
- 2. mount keeps the container element but replaces its innerHTML with the root component's output, synchronously — static fallback markup disappears at that moment
- 3. Plugins, globals, provides, and config hang off the app instance, so each app is an isolated universe and unmount/remount cycles stay clean
- 4. Order is functional, not stylistic: use before mount, pinia before router, because first render is when setup() reads those provides
- 5. One container holds one app's live DOM; mounting again without unmount orphans the previous instance instead of destroying it
Interview Tips
- • "Walk me through what happens between `npm run dev` and the first painted pixel" — index.html loads, main.ts runs createApp, plugins install, mount replaces #app innerHTML synchronously; naming the replacement behavior signals you have debugged a vanished fallback
- • "What breaks if a plugin is registered after mount?" — first render already executed setup(); useRouter() is undefined and Pinia reports no active instance; mention that the fix is moving app.use, not re-mounting
- • "How would you handle errors thrown during render?" — app.config.errorHandler set before mount, receiving (err, instance, info); pair with unmount cleanup and telemetry in tests
- • "How do you mount and destroy a Vue widget repeatedly in a micro-frontend?" — own createApp per mount, app.use only the plugins the widget needs, app.unmount() before the next mount; stress that no state is shared between instances
Cheat Sheet
Application Entry and Mounting
Canonical main.ts:
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App) // instance, no DOM yet
app.use(createPinia()) // before router: guards can use stores
app.use(router) // provides router for every setup()
app.config.errorHandler = (e, i, info) => report(e, info)
app.mount('#app') // first render; container innerHTML replaced
Order rules:
| Step | Must happen | Because |
|---|---|---|
plugin install (app.use) |
before mount |
setup() reads provides during the first render |
createPinia() before router |
before app.use(router) |
navigation guards may call stores |
await router.isReady() |
before mount (optional) |
first paint already has the matched route |
app.unmount() |
before remounting a container | old instance would update detached nodes |
Instance API (all app-scoped): use · component · directive · mixin · provide · config.globalProperties · config.errorHandler / warnHandler · mount / unmount.
Failure signatures:
mount container selector "#app" did not return an element→ id in index.html and main.ts disagreegetActivePinia was called with no active Pinia→ store used beforeapp.use(createPinia())(often at module scope)useRouter()returns undefined → router installed after mount, or called outside setup- widget renders but clicks do nothing → same container mounted twice without unmount