Convert a Vue App to a Mobile App in 2026: Wrap the URL or Bundle with Capacitor?
There are two defensible ways to get a working Vue app onto phones without rewriting it, and they start from opposite ends of your project. Capacitor takes your build output — the dist/ folder Vite produces — and bakes it into a native binary that serves those files locally. A URL wrapper takes the other end: the deployed site itself, rendered live inside a native shell.
We sell the second one. Paste your production URL into our wizard and the pipeline returns a signed Android APK or AAB in a few minutes, with OneSignal push wired in — a native shell rendering your live site, not a native rewrite, and the mechanics are in the docs if you want them. But because Vue is one of the frameworks Capacitor genuinely serves well, this post starts by giving the bundling path its honest due — and then spends the rest on the part nobody writes about: the three places in a Vue or Nuxt codebase that decide whether the wrapped app feels right.
Bundle or wrap: where does the app get its frontend?
Every downstream difference between the two paths falls out of one question: does the app load your frontend from files inside the binary, or from your server?
| Capacitor (bundle) | URL wrapper (this post) | |
|---|---|---|
| Frontend lives | Inside the binary, served locally | On your server, rendered live |
| Shipping an update | New build, new store review | Deploy your site; app updates instantly |
| Offline | Yes, by design | No — it renders your live site |
| Native APIs | Full plugin ecosystem | What the shell provides (push, etc.) |
| Nuxt SSR | Not possible — needs ssr: false or static | Works exactly as deployed |
| Tooling you maintain | Android Studio + Xcode projects | None — cloud build from a URL |
The Nuxt SSR row deserves emphasis because it surprises people: Capacitor serves static files from inside the app package, so a server-rendered Nuxt app has to be rebuilt as ssr: false or a prerendered static export before it can be bundled. Wrapping the deployed URL is the path that keeps your rendering architecture exactly as it is. The deeper comparison — including when neither of these is right and you should build native — is in our Capacitor vs native breakdown.
Two clarifications so nobody buys the wrong thing. If you already went the Capacitor route and your repo has an android/ folder, our separate source-build pipeline compiles that project into a signed binary — a different product surface from URL wrapping. That source path covers Flutter, native Android (Gradle), and Capacitor today; React Native, Ionic, and Expo source builds are waitlist-only.
Audit 1: your router — history mode has a failure case that only shows up in the app
Almost every Vue SPA uses createWebHistory() — clean URLs, no hash. It works in the wrapped app for the same reason it works in mobile Chrome: the WebView is a real browser, and your site is served over HTTPS from a real host. But history mode carries a server-side requirement that a wrapped app exercises far more often than a browser does.
The cold-start deep link
A push notification links to /orders/8127. The user taps it while your app's process is cold, so the WebView's very first request asks your server for that exact path. A static host with no SPA fallback answers 404 — inside your app, as its opening impression. Browser users almost never hit this because they enter at / and navigate client-side; an app that restores state and receives deep links hits it constantly.
The fix is the standard catch-all fallback, applied before you build the app. On nginx:
location / {
try_files $uri $uri/ /index.html;
}On Netlify, a _redirects file containing /* /index.html 200. Vercel and any Nuxt deployment handle it automatically. Verify with one manual test: paste a deep route into mobile Chrome's address bar and load it cold — if that works, the wrapped app's deep links work.
If you genuinely cannot touch the server config, createWebHashHistory() is the blunt escape hatch: the fragment after # is never sent to the server, so nothing can 404. You pay in ugly shareable URLs and weaker SEO — a reasonable trade for an internal tool, a bad one for a public product. One pleasant side effect either way: because vue-router pushes real history entries, Android's back gesture steps backward through your routes naturally, and only exits the app when the stack is empty.
Audit 2: Nuxt rendering mode decides the app's first two seconds
The wrapper renders whatever your deployment serves, so your Nuxt rendering mode passes straight through — and on the mid-range Android hardware most of the world carries, the differences are visible:
- Universal SSR (the default). The server returns finished HTML, so content is on screen at first paint and Vue hydrates afterwards. On a cheap phone this is the difference between reading instantly and staring at a blank viewport while a bundle parses.
- Static / prerendered (
nuxt generate). Same first-paint benefit, no Node server to run. For content-heavy apps this is the best mode to sit behind a wrapper. - ssr: false (pure SPA). Every cold start downloads, parses, and executes the bundle before anything renders. Nuxt lets you style that gap with
app/spa-loading-template.html— worth doing, because in the app that gap plays immediately after your native splash screen, and a styled loader reads as loading while a white flash reads as broken.
You don't have to pick one globally. Nuxt's route rules mix modes per path, and the wrapped app inherits the mix:
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
"/": { prerender: true }, // instant first paint
"/account/**": { ssr: false }, // app-like area, client-only
},
});A plain Vite + Vue SPA behaves like the ssr: false case above: fine on decent hardware, worth testing on a weak device before you ship.
Audit 3: transitions and scrollBehavior — where mid-range Android shows the seams
Vue's <Transition> around RouterView (or Nuxt's page transitions) is where a wrapped app most often betrays that it's a website. Two separate problems, two separate fixes.
First, the scroll jump. Combine a leave transition with vue-router's scrollBehavior and the scroll position changes while the old page is still animating out — a visible lurch to the top mid-transition. The documented fix is to make scrollBehavior wait out the transition by returning a promise:
const router = createRouter({
history: createWebHistory(),
scrollBehavior(to, from, savedPosition) {
// resolve after the 300ms leave transition finishes
return new Promise((resolve) => {
setTimeout(() => resolve(savedPosition ?? { top: 0 }), 320);
});
},
});Returning savedPosition on back navigation matters more in the app than on the web: Android users reflexively use the back gesture, and a list that forgets its scroll position feels broken in a way desktop users never notice.
Second, frame rate. A transition that runs at 60fps on your dev machine can chug on a $150 phone's WebView. The rule is the standard one, it just bites harder here: animate transform and opacity only — transitions that animate height, margins, filters, or box shadows force layout and paint work the GPU can't rescue. If a transition still stutters on real cheap hardware, shorten it to 150–200ms or delete it; no animation reads as faster than a janky one. The wrapper cannot make your site faster than it is in mobile Chrome — that's precisely why the first build being free is useful, as a test harness on the worst device you care about.
Audit 4 (small but sharp): viewport and safe areas
As of mid-2026, Android has been pushing apps edge-to-edge (enforced from Android 15 / target SDK 35), which means fixed headers and bottom nav bars in your Vue app can end up under the status bar or the gesture strip. The web-side fix is the same one iOS taught everyone: declare viewport-fit=cover in your viewport meta tag, then pad fixed elements with env(safe-area-inset-top) and friends, always with a fallback value — Android WebViews have historically reported these insets inconsistently, and older ones return zeros:
.app-header {
padding-top: calc(env(safe-area-inset-top, 0px) + 12px);
}Do this in your CSS once and it's correct in the wrapped app, in Safari on an iPhone, and in every browser that doesn't care.
iOS, stated precisely
Android is the fast path: signed APK/AAB, publish to Google Play under your own $25 developer account. iOS is more constrained, and we'd rather over-specify than let you assume. All cloud iOS .ipa builds require Pro or higher, in two flavors: the default is an unsigned .ipa built on Apple silicon, which you re-sign with your own Apple Developer certificate ($99/year to Apple); or you upload your own .p12 certificate and provisioning profile to an encrypted vault and get a cloud-signed .ipa back. Either way we never need your Apple ID or App Store Connect access, and we do not submit to the App Store for you — the submission, and Apple's review, are yours. The zero-signing route is the iOS Web Clip, a tap-to-install home-screen icon. One genuine consolation: a real Vue application with accounts and functionality has far better odds against Apple's repackaged-website rule (guideline 4.2) than the brochure sites that usually get wrapped.
What it costs
The first Android build is free — one credit, watermark on the splash — which is deliberately enough to run every audit above on a real device. Paid tiers are credit-based: Starter $29/month for 10 builds, Pro $99/month for 30 plus the cloud iOS builds described above, Agency $199/month for 100 with white-label branding and Windows EXE wrappers; annual pricing is $290, $990, and $1,690. A $49 add-on jumps the queue, failed builds refund their credit automatically, and payment is card via Stripe or USDT — details on the pricing page. You'll need an app icon of at least 512×512 (PNG). If you want the generic walkthrough of the wizard itself, the free-conversion guide covers it step by step, and we've done the same audit for React apps if your team runs both frameworks.
FAQ
Do I have to change my Vue code before wrapping it?
Usually not — the app renders your deployed site exactly as it is, so if the site works in mobile Chrome it works in the wrapper. Three things are worth auditing first, though: that your host serves an SPA fallback if you use vue-router's history mode (otherwise deep links 404), that your viewport meta tag includes viewport-fit=cover if you have fixed headers or bottom bars, and that route transitions don't fight your scrollBehavior. All three are fixes in your web codebase, deployed like any other change — no app rebuild needed.
Does vue-router history mode work inside the wrapped Android app?
Yes, with one condition: your host must serve index.html for unknown paths (the standard SPA fallback — try_files on nginx, a /* /index.html 200 rule on Netlify, automatic on Vercel and in Nuxt). Without it, any cold start that lands on a deep route — a push notification link, or Android restoring the app — asks the server for that path directly and gets a 404 inside your app. If you cannot touch the server config, createWebHashHistory is the escape hatch: the hash fragment never reaches the server, so nothing can 404, at the cost of # in every URL.
My app is Nuxt with SSR — does that survive wrapping?
Yes, unchanged. The wrapper renders your live deployed URL, so whatever rendering mode you ship — universal SSR, prerendered static output from nuxt generate, or hybrid routeRules — is exactly what the app shows. Server-rendered HTML actually helps here: content appears on first paint before hydration, which mid-range Android phones notice. This is a genuine difference from Capacitor, which serves files bundled into the binary and therefore requires ssr: false or a static build — a URL wrapper is the path that keeps Nuxt SSR intact.
Should I just use Capacitor instead?
Honestly, maybe — Vue is one of the frameworks Capacitor supports best. Choose Capacitor when you need offline support, deep native plugin access, or the frontend shipped inside the binary; accept that each release then goes through a store review and you maintain the native projects. Choose the URL wrapper when the app is already deployed and you want a signed APK in minutes with instant updates on every deploy. And if you already have a Capacitor project with an android/ folder, our source-build path can compile that into a signed binary too.
What exactly do I get for iOS?
All cloud iOS .ipa builds require the Pro plan or higher, and they come in two flavors: the default is an unsigned .ipa built on Apple silicon that you re-sign with your own Apple Developer certificate, or you can upload your own .p12 certificate and provisioning profile to an encrypted vault and get a cloud-signed .ipa back. We never ask for your Apple ID or App Store Connect access, and App Store submission is yours to do — we do not submit for you. The zero-signing alternative is the iOS Web Clip, a home-screen install with no certificate at all.
What does it cost end to end?
The first Android build is free — one credit, with a watermark on the splash screen. Paid plans are credit-based: Starter $29/month for 10 builds ($290/year), Pro $99/month for 30 plus cloud iOS builds ($990/year), Agency $199/month for 100 with white-label branding and Windows EXE builds ($1,690/year). A $49 add-on jumps a build to the front of the queue, failed builds refund their credit automatically, and we accept card via Stripe or USDT. Store fees are separate: Google Play is $25 one-time, Apple is $99/year if you take the App Store route.
Run the audit on a real device — the first build is free
Fix the SPA fallback, check your transitions, then paste your production URL and put a signed APK on the cheapest Android you can find. Twenty minutes of evidence beats any framework debate, including the one in this post.
Code2Native Engineering
Engineering team
Written by the Code2Native engineering team — the people who build and operate the cloud build pipeline.