FRONTEND · Vue
Vue: consume the API
Scaffold a Vue 3 app with create-vue, add Vue Router, fetch the todo list from your deployed API and build list / show / create / edit / delete with the Composition API and <script setup>.
Prerequisites#
- A deployed Todos API from one of the backend guides. Every backend guide serves the same contract (no trailing slashes):
| Request | Response |
|---|---|
GET /health | {"ok":true} |
GET /todos | [{id,subject,details}] ordered by id |
GET /todos/:id | object or 404 {"error":"not found"} |
POST /todos {subject,details} | 201 object |
PUT /todos/:id {subject,details} | 200 object |
DELETE /todos/:id | 204 |
| blank/missing fields | 400 {"error":"…"} |
Objects contain exactly id (integer), subject, details. CORS allows any origin.
- Node.js 22.18+ or 24.12+ (create-vue's current minimum). Node 24 is the current LTS.
- Nothing installed globally. The old
npm install -g @vue/clistep is gone:npm create vue@latestdownloads the scaffolder on demand.
Pick any backend#
All eight are live and interchangeable:
Every one serves the same contract, so the VITE_API_URL variable below can point at any of them. Each repo ships a check.mjs you can run against your own deploy: node check.mjs https://your-api.up.railway.app.
Setup#
- Scaffold the project. Pass
--routerto have Vue Router installed and wired up, and--bareto skip the example components:
npm create vue@latest todofrontend -- --router --barecd todofrontendnpm install(Run npm create vue@latest -- --help to see every flag; without flags you get interactive prompts. Answer Yes to "Add Vue Router".)
- Point the app at your API. Vite exposes any
VITE_-prefixed variable to the browser throughimport.meta.env. The value is the API's origin only: no/todospath and no trailing slash, because the code builds${API}/todosand${API}/todos/${id}itself..env.localis for the dev server;.env.productionis whatnpm run buildreads and it is safe to commit (the URL is public, not a secret):
# .env.local (and .env.production)VITE_API_URL=https://api-production-a3ab.up.railway.app Leave it unset and the app falls back to http://localhost:8080, which is where every backend guide runs locally.
- Start the dev server with
npm run devand open http://localhost:5173.
Setting Up Vue Router#
create-vue already generated src/router/index.js. Replace its contents with our four routes. Two of them are named so the form and the single-post view can link to them by name and pass the id param.
src/router/index.js
import { createRouter, createWebHistory } from "vue-router"; /////////////////////////// Component imports/////////////////////////import AllPosts from "../pages/AllPosts.vue";import SinglePost from "../pages/SinglePost.vue";import Form from "../pages/Form.vue"; /////////////////////////// Routes/////////////////////////const routes = [ // main page that shows all todos { path: "/", component: AllPosts }, // page for one todo { path: "/post/:id", component: SinglePost, name: "post" }, // create a new todo { path: "/new", component: Form, name: "new" }, // edit an existing todo { path: "/edit/:id", component: Form, name: "edit" },]; export default createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes,});src/main.js is what create-vue generated and it already installs the router. It should look like this:
import { createApp } from "vue";import App from "./App.vue";import router from "./router"; const app = createApp(App); // register the router plugin, then mountapp.use(router);app.mount("#app");Scoping our components#
- Create
src/pagesand add three single-file components. With<script setup>there is noexport defaultboilerplate: whatever you declare at the top level is available in the template.
src/pages/AllPosts.vue
<script setup></script> <template> <h1>AllPosts</h1></template>src/pages/SinglePost.vue
<script setup></script> <template> <h1>SinglePost</h1></template>src/pages/Form.vue
<script setup></script> <template> <h1>Form</h1></template>Setting up the App component#
App.vue holds the list of todos and the function that reloads it, and renders the current route. RouterView exposes the matched component through a slot, so we can pass the shared state down as props explicitly.
src/App.vue
<script setup>import { ref, onMounted } from "vue";import { RouterLink, RouterView } from "vue-router"; // API origin (see .env.local); the paths are built belowconst API = import.meta.env.VITE_API_URL ?? "http://localhost:8080"; // reactive array holding every todoconst posts = ref([]); // fetch the list from the APIasync function getPosts() { const response = await fetch(`${API}/todos`); if (!response.ok) throw new Error(`GET ${API}/todos failed: ${response.status}`); posts.value = await response.json();} // load once when the app mountsonMounted(getPosts);</script> <template> <div class="app"> <h1>Our Todos</h1> <RouterLink to="/new"><button>New Todo</button></RouterLink> <RouterView v-slot="{ Component }"> <component :is="Component" :posts="posts" :api="API" :getPosts="getPosts" /> </RouterView> </div></template> <style>.app { text-align: center;}</style>Getting our todos#
That is already done above: getPosts runs in onMounted and posts is a ref, so every component that receives it re-renders when it changes. Now render the list.
src/pages/AllPosts.vue
<script setup>import { RouterLink } from "vue-router"; defineProps({ posts: { type: Array, default: () => [] } });</script> <template> <div> <div class="post" v-for="post in posts" :key="post.id"> <RouterLink :to="{ name: 'post', params: { id: post.id } }"> <h1>{{ post.subject }}</h1> </RouterLink> <h2>{{ post.details }}</h2> </div> </div></template> <style scoped>.post { text-align: center; border: 3px solid green; margin: 10px auto; width: 80%;}</style>Showing a single post#
Read the id from the route with useRoute, find the post with computed so it updates when the list arrives, and guard the template for the moment before it does.
src/pages/SinglePost.vue
<script setup>import { computed } from "vue";import { RouterLink, useRoute } from "vue-router"; const props = defineProps({ posts: { type: Array, default: () => [] } }); const route = useRoute(); // current route, including paramsconst post = computed(() => props.posts.find((p) => String(p.id) === route.params.id));</script> <template> <div class="post" v-if="post"> <h1>{{ post.subject }}</h1> <h2>{{ post.details }}</h2> <RouterLink to="/"><button>Back to Main</button></RouterLink> </div> <p v-else>Loading…</p></template> <style scoped>button { margin: 10px;}</style>Setting up our form#
One form for both /new and /edit/:id. If the route is named edit, prefill the fields from the matching post and PUT; otherwise POST. v-model binds each input to a ref.
src/pages/Form.vue
<script setup>import { ref, watch } from "vue";import { useRoute, useRouter } from "vue-router"; const props = defineProps({ posts: { type: Array, default: () => [] }, api: { type: String, required: true }, getPosts: { type: Function, required: true },}); const route = useRoute();const router = useRouter(); const subject = ref("");const details = ref(""); const editing = route.name === "edit";const buttonLabel = editing ? "edit todo" : "create todo"; // When editing, fill the form as soon as the matching post is availablewatch( () => props.posts, (posts) => { if (!editing) return; const post = posts.find((p) => String(p.id) === route.params.id); if (post) { subject.value = post.subject; details.value = post.details; } }, { immediate: true }); async function handleSubmit() { const body = JSON.stringify({ subject: subject.value, details: details.value }); const headers = { "Content-Type": "application/json" }; if (editing) { await fetch(`${props.api}/todos/${route.params.id}`, { method: "PUT", headers, body }); } else { await fetch(`${props.api}/todos`, { method: "POST", headers, body }); } await props.getPosts(); // refresh the list router.push("/"); // back to the main page}</script> <template> <form @submit.prevent="handleSubmit"> <input type="text" placeholder="subject" v-model="subject" /> <input type="text" placeholder="details" v-model="details" /> <input type="submit" :value="buttonLabel" /> </form></template>@submit.prevent is the shorthand for v-on:submit with event.preventDefault().
Edit button#
Add an edit link to the single-post view, next to the back button:
<RouterLink :to="{ name: 'edit', params: { id: post.id } }"> <button>Edit Todo</button></RouterLink>Deleting our todos#
Final version of SinglePost.vue: it now also receives api and getPosts, and gets a deletePost function.
src/pages/SinglePost.vue
<script setup>import { computed } from "vue";import { RouterLink, useRoute, useRouter } from "vue-router"; const props = defineProps({ posts: { type: Array, default: () => [] }, api: { type: String, required: true }, getPosts: { type: Function, required: true },}); const route = useRoute();const router = useRouter();const post = computed(() => props.posts.find((p) => String(p.id) === route.params.id)); async function deletePost() { await fetch(`${props.api}/todos/${post.value.id}`, { method: "DELETE" }); await props.getPosts(); router.push("/");}</script> <template> <div class="post" v-if="post"> <h1>{{ post.subject }}</h1> <h2>{{ post.details }}</h2> <RouterLink to="/"><button>Back to Main</button></RouterLink> <RouterLink :to="{ name: 'edit', params: { id: post.id } }"> <button>Edit Todo</button> </RouterLink> <button @click="deletePost">Delete Todo</button> </div> <p v-else>Loading…</p></template> <style scoped>button { margin: 10px;}</style>Done. npm run build produces a static dist/ folder.
The finished example#
The code on this page, built exactly as written, lives at github.com/arthurbernierjr/dtc-vue and runs at dtc-vue-production.up.railway.app. It is pointed at the Go / chi API (https://api-production-a3ab.up.railway.app); to point it at any other backend from the table above, change VITE_API_URL in .env.production and rebuild.
What you will learn#
- How a Vite-based Vue project is laid out (
src/main.js,src/App.vue,src/router/index.js) and howapp.use(router)registers a plugin. - The Composition API in its current form:
<script setup>,ref,computed,watch,onMounted,defineProps. - Vue Router essentials:
createRouter+createWebHistory, dynamic segments (/post/:id), named routes,RouterLink,RouterViewwith a slot, and theuseRoute/useRoutercomposables. - The fetch-list / show-one / create / edit / delete flow against a JSON API, with the same four screens as the React, Angular and Svelte versions.
Resources to Learn More#
See also