VueJS doesn't have to be a Single-Page Application
Author
Tyler James
Date Published

What is VueJS?
VueJS is a progressive JavaScript framework that is incredibly adaptable. It is often brought up along with other popular JavaScript frameworks and libraries such as ReactJS and Angular. It was made by Evan You (also the founder of Vite which is a widely used frontend build tool) and has a large ecosystem of libraries and frameworks that are widely used throughout VueJS applications and pages. VueJS is often compared against ReactJS as they look and function somewhat similarly with a few differences.
The Goal of this Article
Often times Vue is known to be a Single-Page Application, and while it definitely can and it does a great job doing so, I would like to show an alternative way of utilizing Vue. Instead of using full Single-Page Application architecture, Vue can be used to easily plug and play into existing pages and applications with relative ease without the requirement of having a full SPA with routing, state management and all of the other complexities that come with a Single-Page Applications.
This article will not be an in depth guide on how to use Vue (though we will touch a few introductory level concepts). In this article, I will be using raw HTML pages as well as a .NET application with Razor pages to show how we can implement Vue into certain pages, however these same principles and ideas can be utilized across various scenarios. For the examples that will be shown in this post we will be using the modern Vue3 version, however the older Vue2 version may also be used.
Why Would I Not Just Use a Single-Page Application?
Vue does a great job of being a Single-Page Application but there are a few reasons why you may want to go this route rather than building an entire SPA:
- Adoption With Legacy Codebases
Often times as developers we have a pre-existing codebase we need to work with. Let's say that we have a 10 year old .NET MVC Application and we want to add modern Vue functionality to it. To do this would potentially cost months of development time rewriting the entire codebase. - Reduced Complexity
SPAs introduce overhead that may not be justified for your specific use case:
SPA Routing - Separate frontend routing - Build Tooling - Managing the full frontend ecosystem
- API Design - Full API interface is needed for all data
- State Management - For complex state management, some form of state management tooling is needed to track state across various pages (VueX, Pinia)
- Simplifying Deployments
Keeping Vue as a non-SPA integration means there are no changes needed for deployment pipelines - Gradual Adoption Process
Adding Vue to individual pages can allow for a team to get familiar with Vue and slowly start sprinkling in pieces as quickly or as slowly as they want
The Basics with plain HTML
Let's start with a very basic HTML page with a simple table on it:
11<body>22 <div class="container" id="tableapp">33 <h1>User Data Table</h1>44 <table>55 <thead>66 <tr>77 <th>ID</th>88 <th>Name</th>99 <th>Email</th>1010 <th>Role</th>1111 <th>Status</th>1212 </tr>1313 </thead>1414 <tbody>1515 <tr>1616 <td>1</td>1717 <td>John Doe</td>1818 <td>john.doe@example.com</td>1919 <td>Admin</td>2020 <td>Active</td>2121 </tr>2222 <tr>2323 <td>2</td>2424 <td>Jane Smith</td>2525 <td>jane.smith@example.com</td>2626 <td>User</td>2727 <td>Active</td>2828 </tr>2929 <tr>3030 <td>3</td>3131 <td>Bob Johnson</td>3232 <td>bob.johnson@example.com</td>3333 <td>User</td>3434 <td>Inactive</td>3535 </tr>3636 <tr>3737 <td>4</td>3838 <td>Alice Williams</td>3939 <td>alice.williams@example.com</td>4040 <td>Manager</td>4141 <td>Active</td>4242 </tr>4343 </tbody>4444 </table>4545 </div>4646</body>
To add VueJS to this page, it's pretty simple. We need to import Vue and then mount it to an element.
- Add the script to the file
1<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
- Create the Vue instance and mount it to an element (ideally a root level element where you want the Vue functionality to function)
11<script>22 const { createApp, ref } = Vue33 createApp({44 setup() {55 const message = ref('Hello vue!')66 return {77 message88 }99 }1010 }).mount('#tableapp')1111</script>
And just like that, we now have a Vue instance on the page.
- Then we can use that message ref and display it on our page
11<body>22 <div class="container" id="tableapp">33 <!-- Add message here -->44 <div>{{ message }}</div>55 <h1>User Data Table</h1>66 <table>
VueJS proves that modern doesn’t have to mean a full rebuild. You can start small, enhance what you already have, and evolve your app at your own pace—without the overhead of a full SPA. Whether it’s a simple component or a richer interactive experience, Vue gives you the flexibility to modernize on your terms.
Let’s transform this page into a Vue driven page by setting the data in Vue and then adding a search feature.
4. Setting up the table data
11// Within the setup() function22// In practice this would likely come from some API GET request, or some application data (more on this in our .NET example)3344const users = ref([55 {66 id: 1,77 name: 'John Doe',88 email: 'john.doe@example.com',99 role: 'Admin', status: 'Active'1010 },1111 {1212 id: 2,1313 name: 'Jane Smith',1414 email: 'jane.smith@example.com',1515 role: 'User',1616 status: 'Active'1717 },1818 {1919 id: 3,2020 name: 'Bob Johnson',2121 email: 'bob.johnson@example.com',2222 role: 'User',2323 status: 'Inactive'2424 },2525 {2626 id: 4,2727 name: 'Alice Williams',2828 email: 'alice.williams@example.com',2929 role: 'Manager',3030 status: 'Active'3131 }3232])
5. Using Vue Directives to display the user data in the body
11<body>22 <div class="container" id="tableapp">33 <h1>User Data Table</h1>44 <table>55 <thead>66 <tr>77 <th>ID</th>88 <th>Name</th>99 <th>Email</th>1010 <th>Role</th>1111 <th>Status</th>1212 </tr>1313 </thead>1414 <tbody>1515 <!-- Iterate our user data and bind a unique key -->1616 <tr v-for="user in users" :key="user.id">1717 <td>{{ user.id }}</td>1818 <td>{{ user.name }}</td>1919 <td>{{ user.email }}</td>2020 <td>{{ user.role }}</td>2121 <td>{{ user.status }}</td>2222 </tr>2323 </tbody>2424 </table>2525 </div>2626</body>
A note on VueJS syntax
While this won't be a detailed guide on VueJS, I do want to quickly go over what we have so far and some Vue basics.
- Template syntax
The way to display Vue data inside of a template (in our tableapp) is the `{{ }}` syntax where the content inside of the brackets is treated as vue javascript. This could contain a vue defined variable such as our `message` ref, a function, or plain javascript operations like `1 + 1` and the result would display `2`
- Vue operates on a system of directives which are very helpful with interacting with the DOM
- Some common directives:
`v-for` iterates over a given variable (as shown in our user iteration) - `v-if` conditionally renders elements depending on the content within the directive
- `v-model` two way binding which updates data value with input
- for more details on directives visit the vue directive documentation
- creates a new vue application instance
- recommended way to make a value of a variable "reactive" meaning it will play well within VueJS and it can track changes and reactivity of the value
- lifecycle hooks can be used to access vue at various points such as when the instance is mounted on the page. For more information on lifecycle hooks visit the Vue documentation
- Used to return a calculated value based on reactive dependencies (this is why we use refs!) - this will make more sense once we use a computed property for our search functionality
With that out of the way, lets use vue to add a searchbar to our table
6. Add a ref variable for to store our search input
1const searchQuery = ref('')
7. Add our searchbox and bind the input to our searchQuery ref
11<div class="search-box">22 <input33 type="text"44 v-model="searchQuery"55 placeholder="Search by name, email, role, or status..."66>77</div>
8. Add a computed property to return a list of filtered users (by our search input)
11const filteredUsers = computed(() => {22 // If our search input is empty return all users (no search)33 if (!searchQuery.value) {44 return users.value55 }6677 // Set the query to all lowercase to filter regardless of upper/lower88 const query = searchQuery.value.toLowerCase()991010 // filter users by name, email, role, and status using our case insensitive query1111 return users.value.filter(user => {1212 return (1313 user.name.toLowerCase().includes(query) ||1414 user.email.toLowerCase().includes(query) ||1515 user.role.toLowerCase().includes(query) ||1616 user.status.toLowerCase().includes(query)1717 )1818 })1919 })
9. Import computed
11// add computed here22const { createApp, ref, computed } = Vue
10. Update return
11return {22 message,33 searchQuery,44 users,55 filteredUsers66 }
11. Change our table v-for to loop over filteredUsers instead of users
1<tr v-for="user in filteredUsers" :key="user.id">
One last finishing touch before we get into .NET and razor pages with Vue, let's add an external library to add some flair to our table. Let’s use Vuetify (My personal favorite VueJS UI library) to add chips to our active and inactive values on our table.
12. Import the external library
1<script src="https://cdn.jsdelivr.net/npm/vuetify@3.4.9/dist/vuetify.min.js"></script>
13. Define Vuetify inside of our Vue instance
11const { createVuetify } = Vuetifyconst22vuetify = createVuetify()
14. Add use to our Vue instantiation
11// add the .use(vuetify) here22}).use(vuetify).mount('#tableapp')
15. Wrap vue instance content in boilerplate (under the `<div id="tableapp">`)
1<!-- v-app is required by vuetify -->2<v-app>3 <!-- v-main isn't required but it is recommended as it designates the main area of the content and will handle UI better when used in conjunction with other vuetify components like v-footer, etc -->4 <v-main>
16. Add our v-chip component from vuefity
11<td>{{ user.email }}</td>22 <td>{{ user.role }}</td>33 <td>44 <!-- this is our vuetify v-chip component -->55 <v-chip66 :color="user.status === 'Active' ? 'green' : 'red'"77 size="small"88 text-color="white"99>1010 {{ user.status }}1111 </v-chip>1212 </td>
Now we will go into a .NET application example and add Vue to a page and see how it interacts with page data and how we can set up reusable components to be used throughout our application.
First, let's add the Vue library import similar to how we added it on the raw HTML page. Typically in .NET applications, there is a layout file, so we will add it there so it is globally available on all pages that use our layout. I will also be using Bootstrap for styling, so add in bootstrap to the layout file as well.
11<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>22<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
Let’s set up a page with some mock data being returned so we can use that in our Vue instance
11// index.cshtml.cs file2233namespace VueWithDotNet.Pages44{55 public class IndexModel : PageModel66 {77 private readonly ILogger<IndexModel> _logger;8899 // Mock data1010 private static readonly List<Product> _products = new()1111 {1212 new Product { Id = 1, Name = "Laptop", Price = 999.99m, Category = "Electronics", InStock = true },1313 new Product { Id = 2, Name = "Mouse", Price = 29.99m, Category = "Electronics", InStock = true },1414 new Product { Id = 3, Name = "Keyboard", Price = 79.99m, Category = "Electronics", InStock = false },1515 new Product { Id = 4, Name = "Monitor", Price = 299.99m, Category = "Electronics", InStock = true },1616 new Product { Id = 5, Name = "Desk Chair", Price = 199.99m, Category = "Furniture", InStock = true }1717 };18181919 public IndexModel(ILogger<IndexModel> logger)2020 {2121 _logger = logger;2222 }23232424 // Properties accessible in the Razor view2525 public List<Product> Products { get; set; } = new();2626 public int TotalProducts { get; set; }27272828 // Handler for initial page load2929 public void OnGet()3030 {3131 Products = _products;3232 TotalProducts = _products.Count;3333 }3434 }3535}
Let's add a Product class to our `Models/` folder
11namespace VueWithDotNet.Models22{33 public class Product44 {55 public int Id { get; set; }66 public string Name { get; set; } = string.Empty;77 public decimal Price { get; set; }88 public string Category { get; set; } = string.Empty;99 public bool InStock { get; set; }1010 }1111}
Now for our index.cshtml file, lets setup a basic page that uses our model data
11@page22@model IndexModel33@{44 ViewData["Title"] = "Home page";55}6677<div class="container">88 <h1 class="display-4">Product Catalog</h1>991010 <p class="lead">Total Products: @Model.TotalProducts</p>11111212 <div class="row">1313 <div class="col-md-12">1414 <h2>Product List</h2>1515 <table class="table table-striped">1616 <thead>1717 <tr>1818 <th>ID</th>1919 <th>Name</th>2020 <th>Price</th>2121 <th>Category</th>2222 <th>In Stock</th>2323 </tr>2424 </thead>2525 <tbody>2626 @foreach (var product in Model.Products)2727 {2828 <tr>2929 <td>@product.Id</td>3030 <td>@product.Name</td>3131 <td>$@product.Price</td>3232 <td>@product.Category</td>3333 <td>3434 @if (product.InStock)3535 {3636 <span class="badge bg-success">Yes</span>3737 }3838 else3939 {4040 <span class="badge bg-danger">No</span>4141 }4242 </td>4343 </tr>4444 }4545 </tbody>4646 </table>4747 </div>4848 </div>4949</div>50505151@section Scripts {5252 <script type="application/json" id="productsData">5353 @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.Products))5454 </script>5555}
Now let’s instantiate our Vue instance on our index.cshtml page by adding the following underneath our script for Model.Products. So our @section Scripts should be the following
11@section Scripts {22 <script type="application/json" id="productsData">33 @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.Products))44 </script>5566 <script>77 const { createApp, ref } = Vue;8899 // Counter app1010 createApp({1111 setup() {12121313 return {14141515 };1616 }1717 }).mount('#vueApp');1818 </script>1919}
Now let’s add some test functionality using Vue by adding a simple counter with Vue functions. We will add an increment, decrement, and reset button. Later we will create a reusable custom button component that we can reuse on multiple pages.
1. Add our Vue functions and variables and return them
11const count = ref(0);2233// Methods44const increment = () => {55 count.value++;66};7788const decrement = () => {99 count.value--;1010};11111212const reset = () => {1313 count.value = 0;1414};15151616return {1717 count,1818 increment,1919 decrement,2020 reset2121};
2. Add the element root id so that Vue can mount to the page
- You don't have to add the ID to the pages root element like I did here, you can have it containerized to a specific portion of the page if preferred
- By mounting it to the root element like I did, the entire page content will be accessible by Vue
1<div class="container" id="vueApp">
3. Add our counting functionality
11<div class="card p-4">22 <p class="lead">Count: <strong>{{ count }}</strong></p>3344 <div class="btn-group" role="group">55 <button @@click="increment" class="btn btn-primary">66 Increment77 </button>88 <button @@click="decrement" class="btn btn-secondary">99 Decrement1010 </button>1111 <button @@click="reset" class="btn btn-warning">1212 Reset1313 </button>1414 </div>1515</div>
An important note on using Vue event bindings in razor pages (like our click even bindings for each button):
- When using Vue inside of razor pages, we must use double `@@` symbols. This is because a single `@` is reserved by Razor and if we were to use a single `@` razor would try and interpret this as C#
- Usually when using Vue the event bindings (like click) they use a single `@` but for razor pages, we cannot do this.
- Other examples of Vue event are `@change`, `@submit`, `@input`, etc. For more information on Vue events visit the Vue documentation on event handling.
Now let’s take our model data and incorporate it with our Vue instance.
1. Load the model product data into Vue - this should go above or below the `const { createApp, ref } = Vue` line. We can also remove the existing script tag
11// remove this existing tag22<script type="application/json" id="productsData">33 @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.Products))44</script>
11// Add thisconst products22Data = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.Products));
2. In our vue instance setup() function, let’s add the products ref and return it
11const products = ref(productsData);2233...4455return {66 count,77 // Add products to the return88 products,99 increment,1010 decrement,1111 reset12121313};
3. Now similar to our HTML example, we can use that products data inside of our page using Vue
11<div class="col-md-12">22 <h2>Product List</h2>3344 <table class="table table-striped">55 <thead>66 <tr>77 <th>ID</th>88 <th>Name</th>99 <th>Price</th>1010 <th>Category</th>1111 <th>In Stock</th>1212 </tr>1313 </thead>1414 <tbody>1515 <!-- iterate our products list and bind the unique ID -->1616 <tr v-for="product in products" :key="product.Id">1717 <td>{{ product.Id }}</td>1818 <td>{{ product.Name }}</td>1919 <td>${{ product.Price }}</td>2020 <td>{{ product.Category }}</td>2121 <td>2222 <!-- Use vue templating to display yes or no depending on the value of InStock -->2323 {{ product.InStock ? 'Yes' : 'No' }}2424 </td>2525 </tr>2626 </tbody>2727 </table>2828</div>
And just like that, we took our model page and placed it inside of our Vue instance and rendered it on the page using Vue.
Now to finish this off, let's build a reusable button component outside of this page, that we can import and use on other pages. We will use the button component on our simple counter example.
1. Create a `Component/` folder to store our reusable components (I placed this inside of the Pages folder)
2. Add the AppButton.cshtml component
- While we are creating a .cshtml file so that razor can render it via @Html.PartialAsync, the contents inside of this file are primarily vue component code
11@* AppButton Component - Reusable button with type/color variants *@2233<script type="text/x-template" id="app-button-template">44 <button55 :class="buttonClasses"66 :disabled="disabled"77 >88 <slot></slot>99 </button>1010</script>11111212<script type="text/javascript">1313 const AppButtonComponent = {1414 name: 'AppButton',1515 template: '#app-button-template',1616 props: {1717 type: {1818 type: String,1919 default: 'primary',2020 validator: (value) => {2121 return ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'].includes(value);2222 }2323 },2424 size: {2525 type: String,2626 default: 'md',2727 validator: (value) => {2828 return ['sm', 'md', 'lg'].includes(value);2929 }3030 },3131 disabled: {3232 type: Boolean,3333 default: false3434 }3535 },36363737 computed: {3838 buttonClasses() {3939 const classes = ['btn', `btn-${this.type}`];40404141 if (this.size !== 'md') {4242 classes.push(`btn-${this.size}`);4343 }44444545 return classes.join(' ');4646 }4747 }4848 };4949</script>
A few important notes on the component structure:
- `<script type="text/x-template" id="app-button-template">` this tag is important to define as text/x-template and the id must match the template name found in the AppButtonComponent.Template definition
- this .cshtml is to be imported into a Vue instance (like our index page) so it will be treated as vue once imported and used inside of that instance
- Vue naming is interesting in that the "Name" being `AppButton` will be converted to `app-button` when used inside of the DOM. Props are the same way, where if you had a prop defined as `ButtonLabel`, when you use that prop on the component usage it should be `:button-label="labelName"`
- for more information on Vue "props" visit the props documentation
- The `<slot>` is used for content that you want to place in the component that will render in the slot area of the component. So if for example when we place the `app-button` on the index page we can add text to it by the following
11<app-button type="primary">22 <!-- Increment text will be rendered inside of <slot></slot> -->33 Increment44</app-button>
Now to finish this off, let's add our new reusable component to our index page
1. Add the import for the component (under the `ViewData["Title"]` tag)
11@* Import Vue Components *@22@await Html.PartialAsync("Components/AppButton")
2. Register the component in Vue
11// Register components before mounting22// Insert this line before mounting the app with app.mount('#vueApp')33app.component('AppButton', AppButtonComponent);
3. Add our button to our simple counter
11<div class="btn-group" role="group">22 <app-button type="primary" @@click="increment">33 Increment44 </app-button>55 <app-button type="secondary" @@click="decrement">66 Decrement77 </app-button>88 <app-button type="warning" @@click="reset">99 Reset1010 </app-button>1111</div>
Key Takeaway
With the approach that we have taken we have made a .NET application that:
- ✅ Adds a Vue interface to the index page which allows for Vue interactivity (counter, search filtering)
- ✅ Utilizes server-side data with no additional API needed to fetch data (Product data)
- ✅ Created a reusable component that we can use across multiple page to prevent duplicate code (App Button component)
- ✅ No additional build pipeline needed
- ✅ Works with existing deployment pipelines
- ✅ Utilized third party UI library to add additional functionality (Vuetify)
Bonus Vue tools
Bonus Libraries and Frameworks to consider for Vue (that I have used on MPAs!):
- Vuetify - My favorite Vue UI library
- VeeValidate - My favorite Vue frontend validation library
- Vue Devtools - A very helpful web extension tool when debugging vue applications and instances