In this article, I'll provide my feedback after taking part of the VueSchool 2024 Masterclass on Vue.js.
I had taken part in the former version and I learned a lot about Vue 3, Firebase, Pinia and TypeScript. Though the former Masterclass didn't teach about TypeScript, I challenged myself to code along while converting the JavaScript code presented to TypeScript.
The new Masterclass is shorter but deals an intermediate level on most topics on Vue.js with TypeScript in mind. VueSchool has a lot of courses that dives into specific topics that the 2024 Masterclass doesn't cover in details.
My reason to complete the 2024 Masterclass was mainly related to:
- refreshing skills
- understanding the basics about Supabase, an exciting alternative to Firebase
While following the new Masterclass, I have created an opiniated boilerplate that uses Vue for the frontend and Supabase for the backend.
To start, I've run the npm create vue@latest
that uses the latest Vue and Vite. I've chosen the following options:
> npx
> create-vue
Vue.js - The Progressive JavaScript Framework
√ Project name: ... vue-boilerplate-jli
√ Add TypeScript? ... Yes
√ Add JSX Support? ... No
√ Add Vue Router for Single Page Application development? ... Yes
√ Add Pinia for state management? ... Yes
√ Add Vitest for Unit Testing? ... No
√ Add an End-to-End Testing Solution? » No
√ Add ESLint for code quality? » Yes
√ Add Prettier for code formatting? ... Yes
Using a .bashrc
file, you can enable cool shortcut commands in your UNIX Shell terminal. It works great in the Git Bash terminal.
You simply need to source the file to enable the commands:
source .bashrc # from the boilerplate root
Any time I say custom command below, think about .bashrc
.
In the course, Mostafa uses npm scripts, but I ran into an issue when I had to link the remote Supabase project in Visual Studio Code. Plus, using .bashrc
provide autocompletion when using tab in the terminal.
The minimum packages used by the boilerplate are those:
"dependencies": {
"@supabase/supabase-js": "^2.46.1",
"@vueuse/core": "^11.3.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"pinia": "^2.3.0",
"radix-vue": "^1.9.9",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@faker-js/faker": "^9.2.0",
"@tsconfig/node22": "^22.0.0",
"@types/node": "^22.10.2",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-prettier": "^10.1.0",
"@vue/eslint-config-typescript": "^14.1.3",
"@vue/tsconfig": "^0.7.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.14.0",
"eslint-plugin-vue": "^9.30.0",
"npm-run-all2": "^7.0.2",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.15",
"typescript": "~5.6.3",
"unplugin-auto-import": "^0.18.6",
"unplugin-vue-components": "^0.27.5",
"unplugin-vue-router": "^0.10.8",
"vite": "^6.0.5",
"vite-plugin-vue-devtools": "^7.6.8",
"vue-tsc": "^2.1.10"
}
Though in the minimum packages, vueuse/core
is not mandatory, but highly recommended.
If you need Tailwind, make sure to add:
tailwind-merge
andtailwindcss-animate
as dependenciestailwindcss
as development dependency
If you need a boilerplate for a Button
, you'll need to use shadcn for Vue and add class-variance-authority
and radix-vue
.
If you need a boilerplate for a Dropdown
and co, you'll need to use shadcn for Vue and radix-vue
. Run ui-add
custom command with dropdown-menu
to install it.
If you need a boilerplate for a DataTable
, you'll need to add "@tanstack/vue-table": "^8.20.5"
. Then, run ui-add
custom command with table
to install it.
In a Vite-powered app, you want to make sure the sensitive credentials aren't exposed...
All environment variables must be prefixed with VITE_
to be accessible in your application. If you have a variable that is undefined, it is very likely you didn't add the prefix.
Also, with Vite, use import.meta.env
to access the variable instead of the former process.env
.
Environment variables are loaded at build time, not runtime.
You can create different environment files for different modes:
.env
(default for all modes).env.local
(local overrides, ignored by git).env.development
(development mode).env.production
(production mode)
To deploy the app on Netlify, you'll want to import the .env.production
content with your own values.
In my boilerplate, I use the following variables:
# Needed to run the Vue application.
VITE_SUPABASE_URL=https://[your_project_id].supabase.co
VITE_SUPABASE_KEY=[your_project_api_key] # Available at https://supabase.com/dashboard/project/[your_project_id]
# Needed to seed the database, if you need that.
SUPABASE_PROJECT_ID=[your_project_id]
SUPABASE_PROJECT_PASSWORD=[your_db_password]
SUPABASE_PROJECT_SERVICE_ROLE=[your_service_role] # Available at https://supabase.com/dashboard/project/[your_project_id]/settings/api
TESTING_USER_EMAIL="youremail@gmail.com"
I suggest the following folder structure
components
: To store components.- If using
shadcn
, you'll put its components into aui
subfolder. - For the layout, create a
layout
subfolder. - For the rest, I'd recommend to use a flat-directory approach and follow the official Vue.js guidelines about naming the components.
- If using
composables
: To store custom composables. But remember,vueuse
probably has what you need.enums
: See below for more details.plugins
: To store the custom plugins. The boilerplate doesn't contain any.services
: To store anything related to external API.- In the boilerplate, you'll find the services related to authentication, retrieving profiles and the dummy tables.
pages
: To store the pages if you use Unplugin Vue Router.- In the boilerplate, you'll find:
- an
index
(for Home), - a
login
and aregister
page, - a
settings
page, - a profile page (expecting the username as a parameter),
- pages to view the list of dummy entities and a single entity,
- a page to view a sub entity.
- an
- In the boilerplate, you'll find:
router
: To store the router index configuration, even if you use Unplugin Vue Router. That's at least where you define your guards.stores
: To store the Pinia stores.- I included the stores to handle the authentication, the profiles and the errors.
types
: To store types and interfaces for requests, responses, props, events, etc.utils
: To store helpers.- I have included my own date formatter, cache validation (used in Pinia stores) and some basic form validation rules written during the Masterclass.
views
: To store the pages if you use Vue Router.- This is empty. Read more about it in the next paragraph.
Since I started to use Vue Router, I learned how to define routes and guards in the router/index
file.
Nuxt introduced a file-based routing system. But you don't need to use Nuxt to enable this feature. Using
You have two options:
- use the manual routes using Vue Router plugin,
- use the file-based routing using Unplugin Vue Router.
- add a generic catch all
{
//catchAll is a wild card predefined in Vue to match all possible paths
// (.*) matches any characters
// * matches the previous patterns as many times as necessary
path: '/:catchAll(.*)*',
name: 'not-found',
component: () => import('@/views/NotFoundView.vue'),
},
- add a specific catch all
{
//catchAll is a wild card predefined in Vue to match all possible paths
// (.*) matches any characters
// * matches the previous patterns as many times as necessary
path: '/projects:catchAll(.*)*',
name: 'project-not-found',
component: () => import('@/views/NotFoundView.vue'),
},
However you need to make sure the route name is different for each "Not Found" specific route, like above.
Note: The following is simply a detailed explained about using Unplugin Vue Router. I have coded the boilerplate code since I chose to use the file-based routing.
You need to add the following to silence the ESLint rule that reports errors because of the filed-based routing convention:
rules: {
'vue/multi-word-component-names': 0
}
In the router
folder, declare the router using Unplugin Vue Router:
// the import below requires the following line
// to be added to `env.d.ts` to register the global
// typed in the IDE.
// otherwise, there will be an TS error.
//
// <reference types="unplugin-vue-router/client" />
import { createRouter, createWebHistory } from "vue-router/auto";
import { routes } from "vue-router/auto-routes";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});
export default router;
Update env.d.ts
as describe in the comment inside the code above.
And finish by creating a pages
folder with at least an index.vue
file for the homepage.
Oh, and a tip in the script setup
of a page.
If the page receives a parameter, you can get autocompletion from the route value:
const { id } = useRoute("/sub-entities/[id]").params;
Magic strings are string literals used directly in code that have a specific meaning or impact on the program's behavior. They are considered an anti-pattern in software development for several reasons:
- They are hardcoded values embedded directly in the source code
- Their meaning is not immediately clear without additional context
- They are often duplicated across the codebase
- They can be difficult to update consistently if changes are needed
As using TypeScript is recommended in Vue.js, I like to use enums to define once and only once.
When you have something like a list of elements that are either links or button where the first need a to
property to pass on to the RouterLink
whereas the second doesn't, using the <template>
element allows us to add logic in the component's template without rendering a new DOM elements.
So instead of:
<template>
<RouterLink
exact-active-class="text-black bg-green-300"
v-for="link in realLinks"
:key="link.to"
:to="link.to"
class="side-bar-link"
>
<iconify-icon :icon="link.icon"></iconify-icon>
<span class="hidden lg:block text-nowrap">{{ link.label }}</span>
</RouterLink>
<div
v-for="link in nonLinks"
:key="link.label"
class="side-bar-link cursor-pointer"
>
<iconify-icon :icon="link.icon"></iconify-icon>
<span class="hidden lg:block text-nowrap">{{ link.label }}</span>
</div>
</template>
<script setup lang="ts">
import type { LinkProp } from "@/types/LinkProp";
const { links } = defineProps<{
links: LinkProp[];
}>();
const realLinks = links.filter(
(link): link is LinkProp & { to: string } => !!link.to
);
const nonLinks = links.filter(
(link): link is LinkProp & { to: string } => !!!link.to
);
</script>
<style lang="css" scoped>
.side-bar-link {
@apply flex items-center gap-3 px-4 py-2 mx-2 transition-colors rounded-lg hover:text-primary justify-center lg:justify-normal text-muted-foreground;
}
</style>
You can write (see the difference with the second <template>
tag):
<template>
<template v-for="link in links" :key="link.to">
<RouterLink
v-if="link.to"
:to="link.to"
exact-active-class="text-black bg-green-300"
class="side-bar-link"
>
<iconify-icon :icon="link.icon"></iconify-icon>
<span class="hidden lg:block text-nowrap">{{ link.label }}</span>
</RouterLink>
<div v-else class="side-bar-link cursor-pointer">
<iconify-icon :icon="link.icon"></iconify-icon>
<span class="hidden lg:block text-nowrap">{{ link.label }}</span>
</div>
</template>
</template>
<script setup lang="ts">
import type { LinkProp } from "@/types/LinkProp";
import { link } from "fs";
const { links } = defineProps<{
links: LinkProp[];
}>();
</script>
<style lang="css" scoped>
.side-bar-link {
@apply flex items-center gap-3 px-4 py-2 mx-2 transition-colors rounded-lg hover:text-primary justify-center lg:justify-normal text-muted-foreground;
}
</style>
See the documentation on <template>
for more info on the usecases where it works. In fact, not all directives can be used.
It can happen that you might use Pinia stores outside a component, and therefore, you might see the error:
Uncaught Error: [🍍]: "getActivePinia()" was called but there was no active Pinia. Are you trying to use a store before calling "app.use(pinia)"?
You write a dynamic import as follows:
// logout is a exported const function.
// the file `@/utils/supabase-auth.ts` contains an import to use a `authStore`.
const { logout } = await import("@/utils/supabase-auth.ts");
They must be initialized inside a script setup
.
I'm not sure what is the reason for this behavior, if you have some code like the following, the watcher won't execute.
await store.getSubEntity(id);
watch(
() => subEntity.value?.name,
() => {
console.log("watch sub-entity", subEntity.value);
usePageStore().pageData.title = `Sub-Entity: ${
subEntity.value?.name || "Not Sub-Entity found"
}`;
}
);
To solve that, you simply need to swap the asynchronous call and the watcher.
FormKit simplifies the form building.
Let's see how you can add it. The boilerplate already includes it. So to remove it, undo the steps that follow.
Install the package:
npm i @formkit/vue
First, we'll create formkit.theme.ts
file at the project's root.
To doso ,
- Head to the theme website for FormKit
- Copy the command on the homepage for the default theme:
npx formkit theme --theme=regenesis
. - Run it in Visual Studio Code.
You should see the new file now.
If you need another theme, feel free to play around the interface of this website.
Then, add a formkit.config.ts
at the project's root with the following content:
import { defaultConfig } from "@formkit/vue";
import { rootClasses } from "./formkit.theme";
export default defaultConfig({ config: { rootClasses } });
Then, we need to tell TypeScript about those configuration files. In tsconfig.app.json
, add this:
{
// the rest of the file
"include": [
// the rest of the values
"formkit.config.ts",
"formkit.theme.ts"
]
// the rest of the file
}
Next, in main.ts
, we'll import FormKit and tell Vue to use it:
import { plugin } from "@formkit/vue";
import customConfig from "../formkit.config";
const app = createApp(App);
// adding formKit
app.use(plugin, customConfig);
app.mount("#app");
From there, we can start using FormKit. For usage examples, check the boilerplate project.
If you are using Supabase, you'll want to perform the following:
Browse to Supabase's website and sign up with the method of your choice.
Finish with the initialization of your organization and your first project.
IMPORTANT: make sure NOT to include a special character in your project's password. See the ongoing issue about this and if at the time of reading this post, the issue has been resolved.
I recommend using the CLI from Supabase with Scoop rather than the npm package.
That'll avoid the dev dependency in your repository's package.json
.
Run the custom command:
sp-init
# Generate VS Code settings for Deno? y
# Generate IntelliJ Settings for Deno? N
It created a supabase
folder at the root of the project.
Run:
sp-login
It'll prompt you to hit Enter
to copy and paste a single-use code to finish the process.
To do so, you'll need to set the environment variables. Get them from the Supabase Dashboard.
Then, run:
sp-link-env
Important: make sure the browser window that open is where you're logged in to your Supabase account for the project you configured in the .env
file. Otherwise, you might get the error Authorization failed for the access token and project ref pair: {"message":"Failed to retrieve project"}
.
The boilerplate contains that migration. But I'd like to detail how you can create a migration.
The profile table enriches the auth.users
table Supabase provides to you by default. The two tables are linked through id
column.
Here is the migration code I recommend:
drop table if exists profiles;
TRUNCATE auth.users cascade;
create table
profiles (
id uuid references auth.users on delete cascade not null,
created_at timestamptz default now() not null,
updated_at timestamptz null,
username text unique not null,
full_name text not null,
bio text default null,
mode text default 'dark' not null,
avatar_url text default null,
primary key (id)
);
-- This allows to enable row level security on your tables.
-- See https://supabase.com/docs/guides/database/postgres/row-level-security#enabling-row-level-security
alter table "public"."profiles" enable row level security;
-- The following create row level access policies to protect the data to
-- viewed or altered from outside.
-- You can visit this link on your account.
-- Replace {project_id} and {table_id} with your account's data.
-- https://supabase.com/dashboard/project/{project_id}/auth/policies?search={table_id}&schema=public
-- In these examples, only authenticated users can SELECT or INSERT
-- into the `profiles` table.
-- Since Supabase is Zero-Trust, UPDATE and DELETE are denied, unless
-- you declare a policy to state otherwise
create policy "Enable read access for authenticated users only"
on "public"."profiles"
as PERMISSIVE
for SELECT
to authenticated
using (true);
-- This rule prevents any new registration for non-authenticated users.
create policy "Enable insert access for authenticated users only"
on "public"."profiles"
as PERMISSIVE
for INSERT
to authenticated
with check (true);
To test the migration, run the following custom command:
sp-db-reset
It will run all migrations (one for now). Answer to the prompt.
Once completed, you can check the Table Editor on the Supabase dashbord and the profiles
table should appear under the public
schema.
The boilerplate is ready to register any new user. However, Supabase restricts the usage of creating account using the email and password method. Read about the reason why here and there.
Now, you can seed your database. The boilerplate provides a starting point to add a test user.
Make sure to set TESTING_USER_EMAIL
environment variable. It'll be set as the login and password for the test account.
Then, run:
sp-db-seed
The new profile should appear in the profiles
table.
Now, you take the boilerplate as I provide it and try to login with the test account, Supabase will reply Email not confirmed.
This is because we have an option enabled by default.
I have found that the option on the dashboard doesn't solve the issue. You can find it under the Authentication blade, browse to Auth Providers. Select the Email provider and the Confirm email is enabled. To sigin, disable the option.
To solve the problem, you need to modify the seeding procedure to set the email_confirmed_at
to Date.now()
when calling supabase.auth.signUp
.
You'll need to rerun the database reset and seed custom commands.
Then, you can repeat the sign-in. It should succeed.
To avoid a lot of manual work on the Supabase dashboard, I recommend to code your row-level policies.
You need to think about it at least when you create any table. Then, depending on your business rules, you may add more specific rules.
In the boilerplate, I've added two dummy tables to demonstrate a few concepts about writting your migration and reading the values from those tables in the Vue application.
When deploying on Netlify a Vue application, you'll need a file to avoid 404 erros. That file goes into the public directory.
Name the file _redirects
and paste the following content:
/* /index.html 200
It tells Netlify to redirect all request to the index file and let Vue handle the page requests.
Use your .env
file to import the value needed to run the application on Netlify.
Mostafa's teaching skills helped me to refresh my skills on Vue.js and learn how to use Supabase.
I still recommend VueSchool courses and I would also try their certification plateform now providing certifications for Vue.js, Angular, Nuxt and plain old JavaScript.