From Web Code to Native Desktop Apps
JavaScript's reach has extended far beyond the browser. Since Node.js emerged in 2009 as a cross-platform runtime, the same language powering web interfaces has been used for server-side development and, increasingly, desktop software. Electron builds on this by pairing Node.js with the Chromium rendering engine, giving developers a runtime that can produce native-feeling GUI applications for Windows, macOS, and Linux from a single HTML, CSS, and JavaScript codebase.
Electron is maintained by GitHub and was originally known as “Atom shell.” It powers well-known tools such as the Atom editor, Visual Studio Code, Slack, and WordPress for desktop. Electron apps run stand-alone, unlike web applications that require a browser and an internet connection. Familiar desktop software like Microsoft Word, web browsers, and Adobe Photoshop exemplify this installed, locally executed model of operation.
Several other frameworks target desktop development, including Java (which follows the “write once, run anywhere” philosophy), JavaFX for client applications across desktop, mobile, and embedded systems, C#, and the cross-platform .NET platform, which supports multiple languages and targets including web, mobile, and gaming.
Before integrating Vue.js with Electron, you need the Electron package added to your project. Install it locally with npm for project-specific use:
npm install electron --save-dev
For frequent work on Electron apps, a global installation avoids repeating this step:
npm install electron -g
Extending Vue Apps To The Desktop With Electron
If you already know how to build web applications with Vue, you can apply that same knowledge to desktop software. The vue-cli-plugin-electron-builder tool wraps your existing Vue project in an Electron shell, so the same codebase that runs in the browser can also run as a native desktop application across Windows, macOS and Linux.
To demonstrate the workflow, we’ll build a news reader around the News API. Registration is free and gives you a personal key for requesting live data. The finished app will show breaking headlines from a user-selected country through the /top-headlines endpoint, and it will pull stories from a chosen category using the /everything endpoint with a q query parameter.
Start by making sure the Vue CLI is installed globally, then scaffold the project:
npm install -g @vue/cli
# OR
yarn global add @vue/cli
vue create news-app
For network requests we’ll use Axios, although any HTTP client works:
//NPM
npm install axios
// YARN
yarn add axios
Next, create a plugins directory inside src and add an axios.js file that exports a configured instance:
import axios from "axios";
let baseURL = `https://newsapi.org/v2`;
let apiKey = process.env.VUE_APP_APIKEY;
const instance = axios.create({
baseURL: baseURL,
timeout: 30000,
headers: {
"X-Api-Key": apiKey,
},
});
export default instance;
That file sets the baseURL shown above, attaches your apiKey to the X-Api-Key header (one of the three authentication methods News API supports), and applies a timeout. With the instance in place, add Electron support through the CLI:
vue add electron-builder
During installation you’ll be asked for an Electron release — choosing version 9.0.0 is fine here as it was the current stable build at the time of writing. Launch the app in development with:
Using Yarn(strongly recommended)
yarn electron:serve
OR NPM
npm run electron:serve
Compilation takes a moment, then the Electron window appears with the default Vue view:
The point of this plugin is that development stays exactly like a normal Vue project; the Electron layer only changes how the result is delivered. Our example is organised in three views:
- a landing page showing top stories from a country picked at random;
- a page letting the user choose a country for top headlines;
- a page for headlines in a category the user selects.
We start with a shared header.vue component in the components folder:
<template>
<header class="header">
<div class="logo">
<div class="logo__container">
<img src="../assets/logo.png" alt="News app logo" class="logo__image" />
</div>
<h1>News App</h1>
</div>
<nav class="nav">
<h4 class="nav__link">
<router-link to="/home">Home</router-link>
</h4>
<h4 class="nav__link">
<router-link to="/top-news">Top News</router-link>
</h4>
<h4 class="nav__link">
<router-link to="/categories">News By Category</router-link>
</h4>
</nav>
</header>
</template>
<script>
export default {
name: "app-header",
};
</script>
<style>
.header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
.logo {
display: flex;
flex-wrap: nowrap;
justify-content: space-between;
align-items: center;
height: 50px;
}
.logo__container {
width: 50px;
height: 50px;
}
.logo__image {
max-width: 100%;
max-height: 100%;
}
.nav {
display: flex;
flex-wrap: wrap;
width: 350px;
justify-content: space-between;
}
</style>
The header holds the app name, a logo (the image used here is available in the GitHub repository for this project) and navigation links. It is imported into App.vue so it stays visible on every route:
<template>
<div id="app">
<app-header />
<router-view />
</div>
</template>
<script>
import appHeader from "@/components/Header.vue";
export default {
name: "layout",
components: {
appHeader,
},
};
</script>
<style>
@import url("https://fonts.googleapis.com/css2?family=Abel&family=Staatliches&display=swap");
html,
#app {
min-height: 100vh;
}
#app {
font-family: "Abel", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
background-color: #fff;
}
#app h1 {
font-family: "Staatliches", cursive;
}
a {
font-weight: bold;
color: #2c3e50;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
a.router-link-exact-active {
color: #42b983;
}
</style>
With the layout set, the app displays the header over each page:
Fetching And Displaying News
The landing page lives in Home.vue. In its script section it pulls mapState and mapActions from Vuex, imports a NewsCard component, and selects a random country from the store before dispatching the getTopNews action:
<template>
<section class="home">
<h1>Welcome to News App</h1>
<h4>Displaying Top News from {{ countryInfo.name }}</h4>
<div class="articles__div" v-if="articles">
<news-card
v-for="(article, index) in articles"
:key="index"
:article="article"
></news-card>
</div>
</section>
</template>
<script>
import { mapActions, mapState } from "vuex";
import NewsCard from "../components/NewsCard";
export default {
data() {
return {
articles: "",
countryInfo: "",
};
},
components: {
NewsCard,
},
mounted() {
this.fetchTopNews();
},
computed: {
...mapState(["countries"]),
},
methods: {
...mapActions(["getTopNews"]),
async fetchTopNews() {
let countriesLength = this.countries.length;
let countryIndex = Math.floor(
Math.random() * (countriesLength - 1) + 1
);
this.countryInfo = this.countries[countryIndex];
let { data } = await this.getTopNews(
this.countries[countryIndex].value
);
this.articles = data.articles;
},
},
};
</script>
<style>
.articles__div {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
</style>
That action appends the chosen country as a query string to the endpoint (baseURL/top-news?country=…). Articles returned by the request are iterated and passed into NewsCard components through the article prop. A short paragraph tells the user which country the headlines come from.
The NewsCard.vue component renders a single article and lazy-loads its images:
<template>
<section class="news">
<div class="news__section">
<h1 class="news__title">
<a class="article__link" :href="article.url" target="_blank">
{{ article.title }}
</a>
</h1>
<h3 class="news__author" v-if="article.author">{{ article.author }}</h3>
<!-- <p class="article__paragraph">{{ article.description }}</p> -->
<h5 class="article__published">{{ new Date(article.publishedAt) }}</h5>
</div>
<div class="image__container">
<img
class="news__img"
src="../assets/logo.png"
:data-src="article.urlToImage"
:alt="article.title"
/>
</div>
</section>
</template>
<script>
export default {
name: "news-card",
props: {
article: Object,
},
mounted() {
this.lazyLoadImages();
},
methods: {
lazyLoadImages() {
const images = document.querySelectorAll(".news__img");
const options = {
// If the image gets within 50px in the Y axis, start the download.
root: null, // Page as root
rootMargin: "0px",
threshold: 0.1,
};
const fetchImage = (url) => {
return new Promise((resolve, reject) => {
const image = new Image();
image.src = url;
image.onload = resolve;
image.onerror = reject;
});
};
const loadImage = (image) => {
const src = image.dataset.src;
fetchImage(src).then(() => {
image.src = src;
});
};
const handleIntersection = (entries) => {
entries.forEach((entry) => {
if (entry.intersectionRatio > 0) {
loadImage(entry.target);
}
});
};
// The observer for the images on the page
const observer = new IntersectionObserver(handleIntersection, options);
images.forEach((img) => {
observer.observe(img);
});
},
},
};
</script>
<style>
.news {
width: 100%;
display: flex;
flex-direction: row;
align-items: flex-start;
max-width: 550px;
box-shadow: 2px 1px 7px 1px #eee;
padding: 20px 5px;
box-sizing: border-box;
margin: 15px 5px;
border-radius: 4px;
}
.news__section {
width: 100%;
max-width: 350px;
margin-right: 5px;
}
.news__title {
font-size: 15px;
text-align: left;
margin-top: 0;
}
.news__author {
font-size: 14px;
text-align: left;
font-weight: normal;
}
.article__published {
text-align: left;
}
.image__container {
width: 100%;
max-width: 180px;
max-height: 180px;
}
.news__img {
transition: max-width 300ms cubic-bezier(0.4, 0, 1, 1),
max-height 300ms cubic-bezier(0.4, 0, 1, 1);
max-width: 150px;
max-height: 150px;
}
.news__img:hover {
max-width: 180px;
max-height: 180px;
}
.article__link {
text-decoration: none;
color: inherit;
}
</style>
Inside it, an article object prop carries the story data, while a method checks which images are close to the viewport and loads them only when they become visible.
The store, set up in index.js, keeps two pieces of shared state: an array of countries and an array of categories available through the News API. Both lists are reused across the app, which is why they belong in the store rather than being redefined per component. The getTopNews action receives a country code and retrieves the headlines for it:
import Vue from "vue";
import Vuex from "vuex";
import axios from "../plugins/axios";
Vue.use(Vuex);
const store = new Vuex.Store({
state: {
countries: [{
name: "United States of America",
value: "us",
},
{
name: "Nigeria",
value: "ng",
},
{
name: "Argentina",
value: "ar",
},
{
name: "Canada",
value: "ca",
},
{
name: "South Africa",
value: "za",
},
],
categories: [
"entertainment",
"general",
"health",
"science",
"business",
"sports",
"technology",
],
},
mutations: {},
actions: {
async getTopNews(context, country) {
let res = await axios({
url: `/top-headlines?country=${country}`,
method: "GET",
});
return res;
},
},
});
export default store;
Opening the app now shows the landing page with the top stories from a random country:
Customising The Electron Shell
Electron behaviour is controlled from the background.js file — the process that runs outside the renderer and sets options such as window size. Several common tweaks live there.
Enabling Vue Devtools
Developer tools are technically available in development, but they are disabled after installation due to a bug on Windows 10. Look at your background.js and you will find the related code commented out with an explanation:
// Install Vue Devtools
// Devtools extensions are broken in Electron 6.0.0 and greater
// See https://github.com/nklayman/vue-cli-plugin-electron-builder/issues/378 for more info
// Electron will not launch with Devtools extensions installed on Windows 10 with dark mode
// If you are not using Windows 10 dark mode, you may uncomment these lines
// In addition, if the linked issue is closed, you can upgrade electron and uncomment these lines
// try {
// await installVueDevtools()
// } catch (e) {
// console.error('Vue Devtools failed to install:', e.toString())
// }
If you are not affected by that bug, uncomment the try/catch block and the installVueDevtools call on line 5 of the same file. After the app restarts, the devtools pick up the Vuejs Devtools extension:
Swapping In A Custom Icon
Electron uses its own icon unless you tell it otherwise. Place your image in the public folder and rename it to icon.png, then install the conversion helper:
// With Yarn:
yarn add --dev electron-icon-builder
// or with NPM:
npm install --save-dev electron-icon-builder
Running the builder converts the image to the format Electron expects and confirms the result in the console:
The converted icon is wired up by adding an icon option to the BrowserWindow configuration inside background.js:
// Add this to the top of your file
/* global __static */
// import path
import path from 'path'
// Replace
win = new BrowserWindow({ width: 800, height: 600 })
// With
win = new BrowserWindow({
width: 800,
height: 600,
icon: path.join(__static, 'icon.png')
})
After that change, a production build (yarn run electron:build) shows the new icon, but the development mode does not. Known workarounds for macOS are collected in a related GitHub issue.
Setting The Window Title
By default the Electron window title matches the project name (here news-app). Override it with a title property in the BrowserWindow configuration:
win = new BrowserWindow({
width: 600,
height: 500,
title: "News App",
icon: path.join(__static, "icon.png"),
webPreferences: {
// Use pluginOptions.nodeIntegration, leave this alone
// See nklayman.github.io/vue-cli-plugin-electron-builder/guide/security.html#node-integration for more info
nodeIntegration: process.env.ELECTRON_NODE_INTEGRATION,
},
});
That sets the title to “News App”. If the value in index.html still wins, add the snippet below, which stops Electron from replacing the title when index.html loads:
win.on("page-title-updated", (event) => event.preventDefault());
The productName is worth changing too — it is the label shown on hover and in system tooling. Create a vue.config.js file to rename the production app:
module.exports = {
pluginOptions: {
electronBuilder: {
builderOptions: {
productName: "News App",
},
},
},
};
With this in place, build output names the app “News App” instead of “Electron”.
Building For Other Platforms
Running the build command produces an installer for the current operating system only — a Linux command yields a Linux app, and the same holds for macOS and Windows. Electron lets you override this and target a specific platform or multiple platforms at once. The recognised options are mac, win and linux. To emit a Windows package from any host machine, for example, use:
// NPM
npm electron:build -- --win nsis
// YARN
yarn electron:build --win nsis
Where To Go From Here
The example app you built throughout this walkthrough is available on GitHub. It covers the basics of wiring Vue into an Electron shell, but the framework opens up far more desktop-specific possibilities than what this tutorial demonstrates.
Two areas worth exploring on your own involve the native window and OS integration:
- macOS dock customization — Control how your app presents itself in the dock with the official Electron guide.
- BrowserWindow options — Configure behavior such as whether the window is
resizeableormaximizable. The full list of properties is documented in the BrowserWindow API reference.
For anything beyond these examples, the official Electron documentation is the recommended starting point.
Additional Resources
To deepen your understanding of the underlying technologies or for related reading, the following links may be useful:
- Node.js
- electronjs
- Electron Documentation: First App
- Vue CLI Plugin Electron Builder
- axios
- Lazy Loading Images for Performance Using Intersection Observer
Other Tech Report articles that touch on related engineering topics include:
- Optimizing A Vue App
- Getting Started With Axios In Nuxt
- Long Live The Test Pyramid
- A Simple Guide To Retrieval Augmented Generation Language Models




