Skip to main content

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:

  1. Why do Vite environment variables need the VITE_ prefix to be accessible in component code? — Vite only exposes variables prefixed with VITE_ to client-side code via import.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 in vite.config.ts, which runs in Node.js at build time.
  2. 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.
  3. 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.
  4. What is the standard pattern for handling loading and error states when fetching data? — Use three reactive variables: data (the result), isLoading (boolean), and error (string or null). Set isLoading = true before the request, then in the try block set data to the response and error to null, in the catch block set error to the message, and in finally set isLoading = false. In the template, use v-if="isLoading" for a loading indicator, v-else-if="error" for the error message, and v-else for the data display.
  5. 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-Origin headers. During development, your Vite dev server (e.g., localhost:5173) and your API (e.g., localhost:5000) are different origins. Vite's server.proxy config 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.
  6. 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 reactive data, isLoading, and error refs that update automatically, while a utility formatDate() just transforms input and returns output. Composables are the Composition API's answer to mixins.
  7. What is the difference between .env, .env.production, and .env.local files?.env contains default values used in all environments. .env.production overrides those defaults when building for production (npm run build). .env.development overrides for npm 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.