Vue 04 - API Integration
Environment variables
Vite exposes environment variables via import.meta.env. Variables must be prefixed with VITE_ to be available in client code.
Create .env files in project root:
# .env — shared defaults
VITE_API_BASE_URL=https://localhost:5001/api/v1/
# .env.production — production overrides
VITE_API_BASE_URL=https://taltech.akaver.com/api/v1/
Use in code:
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
Add .env*.local to .gitignore for secrets that should not be committed.
Using REST APIs with Axios
Axios is a promise-based HTTP client for the browser and Node.js.
npm install axios
Basic usage with async/await
import axios from "axios";
async function getUser(id: number) {
try {
const response = await axios.get(`/api/users/${id}`);
console.log(response.data);
} catch (error: unknown) {
console.error("Failed to fetch user:", error);
}
}
Typed API service
Create a reusable service class with typed responses. Use environment variables for the base URL.
// src/types/IResultObject.ts
export interface IResultObject<TData> {
errors?: string[];
data?: TData;
}
// src/services/EntityService.ts
import axios from "axios";
import type { IResultObject } from "@/types/IResultObject";
export default class EntityService<TEntity> {
private httpClient;
constructor(basePath: string) {
this.httpClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL + basePath,
});
}
async getAll(): Promise<IResultObject<TEntity[]>> {
try {
const response = await this.httpClient.get<TEntity[]>("");
return { data: response.data };
} catch (error: unknown) {
return {
errors: [error instanceof Error ? error.message : "Unknown error"],
};
}
}
async getById(id: string): Promise<IResultObject<TEntity>> {
try {
const response = await this.httpClient.get<TEntity>(id);
return { data: response.data };
} catch (error: unknown) {
return {
errors: [error instanceof Error ? error.message : "Unknown error"],
};
}
}
}
Axios interceptors
Interceptors modify requests or responses globally — for example, attaching the JWT token to every request automatically.
// src/services/httpClient.ts
import axios from "axios";
import { useAuthStore } from "@/stores/auth";
const httpClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
});
// Request interceptor — attach JWT to every request
httpClient.interceptors.request.use((config) => {
const authStore = useAuthStore();
if (authStore.jwt) {
config.headers.Authorization = `Bearer ${authStore.jwt}`;
}
return config;
});
// Response interceptor — handle 401 globally
httpClient.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
// Token expired — could trigger refresh or redirect to login
const authStore = useAuthStore();
authStore.$reset();
}
return Promise.reject(error);
}
);
export default httpClient;
Then use this shared httpClient in all services instead of creating separate Axios instances.
Authentication
Auth store
// src/stores/auth.ts
import { ref, computed } from "vue";
import { defineStore } from "pinia";
export const useAuthStore = defineStore("auth", () => {
const jwt = ref<string | null>(null);
const refreshToken = ref<string | null>(null);
const userName = ref<string | null>(null);
const isAuthenticated = computed(() => !!jwt.value);
return { jwt, refreshToken, userName, isAuthenticated };
});
Auth types
// src/types/IUserInfo.ts
export interface IUserInfo {
token: string;
refreshToken: string;
firstName: string;
lastName: string;
}
Login service
// src/services/AccountService.ts
import type { IResultObject } from "@/types/IResultObject";
import type { IUserInfo } from "@/types/IUserInfo";
import axios from "axios";
export default class AccountService {
private static httpClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL + "account/",
});
static async loginAsync(
email: string,
password: string
): Promise<IResultObject<IUserInfo>> {
try {
const response = await this.httpClient.post<IUserInfo>("login", {
email,
password,
});
if (response.status < 300) {
return { data: response.data };
}
return {
errors: [response.status.toString() + " " + response.statusText],
};
} catch (error: unknown) {
return {
errors: [error instanceof Error ? error.message : "Unknown error"],
};
}
}
}
Login view
<script setup lang="ts">
import AccountService from "@/services/AccountService";
import { useAuthStore } from "@/stores/auth";
import { useRouter } from "vue-router";
import { ref } from "vue";
const authStore = useAuthStore();
const router = useRouter();
const loginName = ref("");
const loginPassword = ref("");
const loginIsOngoing = ref(false);
const errors = ref<string[]>([]);
const doLogin = async () => {
loginIsOngoing.value = true;
errors.value = [];
const res = await AccountService.loginAsync(
loginName.value,
loginPassword.value
);
if (res.data) {
authStore.jwt = res.data.token;
authStore.refreshToken = res.data.refreshToken;
authStore.userName = res.data.firstName + " " + res.data.lastName;
router.push({ name: "Home" });
} else {
errors.value = res.errors ?? ["Login failed"];
}
loginIsOngoing.value = false;
};
</script>
<template>
<h1>Log in</h1>
<div class="row">
<div class="col-md-6">
<form @submit.prevent="doLogin">
<div v-if="errors.length > 0" class="alert alert-danger">
<div v-for="error in errors" :key="error">{{ error }}</div>
</div>
<div class="form-floating mb-3">
<input
v-model="loginName"
class="form-control"
autocomplete="username"
placeholder="name@example.com"
type="email"
id="loginEmail"
/>
<label for="loginEmail">Email</label>
</div>
<div class="form-floating mb-3">
<input
v-model="loginPassword"
class="form-control"
autocomplete="current-password"
placeholder="password"
type="password"
id="loginPassword"
/>
<label for="loginPassword">Password</label>
</div>
<button
type="submit"
class="w-100 btn btn-lg btn-primary"
:disabled="loginIsOngoing"
>
{{ loginIsOngoing ? "Logging in..." : "Log in" }}
</button>
</form>
</div>
</div>
</template>
Loading and error patterns
A reusable pattern for views that fetch data:
<script setup lang="ts">
import { ref, onMounted } from "vue";
import type { IOwner } from "@/types/IOwner";
import EntityService from "@/services/EntityService";
const ownerService = new EntityService<IOwner>("owners/");
const owners = ref<IOwner[]>([]);
const isLoading = ref(true);
const errorMessage = ref<string | null>(null);
onMounted(async () => {
const result = await ownerService.getAll();
if (result.data) {
owners.value = result.data;
} else {
errorMessage.value = result.errors?.join(", ") ?? "Failed to load";
}
isLoading.value = false;
});
</script>
<template>
<h1>Owners</h1>
<div v-if="isLoading">Loading...</div>
<div v-else-if="errorMessage" class="alert alert-danger">
{{ errorMessage }}
</div>
<div v-else>
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="owner in owners" :key="owner.id">
<td>{{ owner.name }}</td>
<td>
<RouterLink :to="{ name: 'OwnersEdit', params: { id: owner.id } }">
Edit
</RouterLink>
</td>
</tr>
</tbody>
</table>
</div>
</template>
CORS
When your frontend (e.g., localhost:5173) calls an API on a different origin (e.g., localhost:5001), the browser blocks the request due to Cross-Origin Resource Sharing (CORS) policy.
The fix is on the server side — the API must include CORS headers (Access-Control-Allow-Origin). In development, you can also use Vite's proxy:
// vite.config.ts
export default defineConfig({
server: {
proxy: {
"/api": {
target: "https://localhost:5001",
changeOrigin: true,
},
},
},
});
Composables
Composables are functions that encapsulate reusable stateful logic using Vue's Composition API — similar to React's custom hooks.
// src/composables/useApi.ts
import { ref } from "vue";
import type { IResultObject } from "@/types/IResultObject";
export function useApi<T>(apiFn: () => Promise<IResultObject<T>>) {
const data = ref<T>();
const isLoading = ref(false);
const error = ref<string | null>(null);
const execute = async () => {
isLoading.value = true;
error.value = null;
const result = await apiFn();
if (result.data) {
data.value = result.data;
} else {
error.value = result.errors?.join(", ") ?? "Unknown error";
}
isLoading.value = false;
};
return { data, isLoading, error, execute };
}
Usage in a component:
<script setup lang="ts">
import { onMounted } from "vue";
import { useApi } from "@/composables/useApi";
import EntityService from "@/services/EntityService";
import type { IOwner } from "@/types/IOwner";
const ownerService = new EntityService<IOwner>("owners/");
const { data: owners, isLoading, error, execute } = useApi(() =>
ownerService.getAll()
);
onMounted(execute);
</script>
<template>
<div v-if="isLoading">Loading...</div>
<div v-else-if="error">{{ error }}</div>
<ul v-else>
<li v-for="owner in owners" :key="owner.id">{{ owner.name }}</li>
</ul>
</template>
Defence Preparation
Be prepared to explain topics like these:
- Why do Vite environment variables need the
VITE_prefix to be accessible in component code? — Vite only exposes variables prefixed withVITE_to client-side code viaimport.meta.env. This is a security measure — without the prefix filter, all environment variables (including secrets like database passwords or API keys) would be embedded into the JavaScript bundle that is sent to the browser. Variables without the prefix are only available invite.config.ts, which runs in Node.js at build time. - How do Axios interceptors work, and how are they used for JWT authentication? — Interceptors are functions that run before every request (request interceptor) or after every response (response interceptor). For JWT auth, a request interceptor reads the token from the auth store and attaches it as an
Authorization: Bearer <token>header to every outgoing request. A response interceptor can catch 401 errors globally — triggering a token refresh or redirecting to the login page — instead of handling auth failures in every individual API call. - What should happen when the server responds with a 401 status code? How would you handle this in a Vue app? — A 401 means the user's authentication token is invalid or expired. In a Vue app, the response interceptor catches the 401, clears the stored JWT from the auth store, and redirects to the login page using
router.push('/login'). Optionally, you can attempt a token refresh first — send the refresh token to get a new JWT, retry the original request, and only redirect to login if the refresh also fails. - What is the standard pattern for handling loading and error states when fetching data? — Use three reactive variables:
data(the result),isLoading(boolean), anderror(string or null). SetisLoading = truebefore the request, then in thetryblock setdatato the response anderrorto null, in thecatchblock seterrorto the message, and infinallysetisLoading = false. In the template, usev-if="isLoading"for a loading indicator,v-else-if="error"for the error message, andv-elsefor the data display. - What is CORS, and how can you work around it during development with Vite? — CORS blocks frontend JavaScript from reading responses from a different origin unless the server sends
Access-Control-Allow-Originheaders. During development, your Vite dev server (e.g.,localhost:5173) and your API (e.g.,localhost:5000) are different origins. Vite'sserver.proxyconfig can proxy API requests through the dev server, making them same-origin from the browser's perspective. In production, configure the backend to send proper CORS headers or serve everything from the same origin. - What is a composable in Vue, and how does it differ from a utility function? — A composable is a function (conventionally named
useXxx) that uses Vue's Composition API —ref(),computed(),watch(), lifecycle hooks — to encapsulate and reuse stateful logic. A plain utility function has no reactive state. For example,useApi()returns reactivedata,isLoading, anderrorrefs that update automatically, while a utilityformatDate()just transforms input and returns output. Composables are the Composition API's answer to mixins. - What is the difference between
.env,.env.production, and.env.localfiles? —.envcontains default values used in all environments..env.productionoverrides those defaults when building for production (npm run build)..env.developmentoverrides fornpm run dev. Files ending in.local(e.g.,.env.local) are never committed to Git — they are for personal overrides like local API URLs or secrets. More specific files take priority:.env.production.local>.env.production>.env.local>.env.