More often than not, when building your JavaScript application, you’ll want to fetch data from a remote source or consume an API. There’s lots of cool stuff that can be done with data from a range of publicly available APIs.
With Vue.js, you can literally build an app around one of these services and start serving content to users in minutes.
I’ll demonstrate how to build a simple news app that will show the top news articles of the day, and that will allow users to filter by their category of interest, fetching data from the New York Times API. You can find the complete code for this tutorial here, and a live demo of the finished app here.
Here’s what the final app will look like:
To follow along with this tutorial, you’ll need Node.js and (optionally) Yarn installed on your machine. To install Node, you can either head to the official download page and grab the Node binaries for your system, or use a version manager instead.
Once Node is installed, to pull in Yarn, run:
npm i -g yarn
You’ll also require a basic knowledge of Vue.js. You can find a great getting started guide for that here.
Acquire an API Key
To make use of the NYTimes API, you’ll need to get an API key. So if you don’t already have one, head over to their signup page and register to get an API key for the Top Stories API.
We’ll be using the top stories API endpoint to fetch data from. Take note that there are multiple sections such as “home”, “travel”, “arts” and “science” that this API provides. We’ll need to build a filter that can allow users to select a section and load the stories within it.
Below are example calls:
https://api.nytimes.com/svc/topstories/v2/arts.json?api-key=yourkey
https://api.nytimes.com/svc/topstories/v2/home.json?api-key=yourkey
https://api.nytimes.com/svc/topstories/v2/science.json?api-key=yourkey
https://api.nytimes.com/svc/topstories/v2/us.json?api-key=yourkey
https://api.nytimes.com/svc/topstories/v2/world.json?api-key=yourkey
Feel free to use your favorite REST client (such as Hoppscotch or Insomnia) to test your API calls.
Project Structure
Let’s quickly spin up a Vue 3 project using Vite, a dev server that runs faster than Vue CLI:
yarn create @vitejs/app vue-news-app --template vue
# Install package dependencies
cd vue-news-app
yarn install
# Confirm app can run
yarn dev
Open localhost:3000
in your browser. You should have the following view:
Next, let’s install the TailwindCSS framework to provide some basic styling. You’ll need to stop the server in order to perform this action:
yarn add -D tailwindcss@latest postcss@latest autoprefixer@latest
# Generate tailwind.config.js and postcss.config.js files
npx tailwindcss init -p
We’ll need some additional package utilities to help us format dates (date-fns) and clamp the number of lines (tailwind’s line-clamp) for the abstract
field:
yarn add @tailwindcss/line-clamp date-fns
@tailwindcss/line-clamp
is a plugin that needs to be included in tailwind.config.js
. Below is the full configuration:
module.exports = {
purge: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
darkMode: false, // or 'media' or 'class'
theme: {
extend: {},
},
variants: {
extend: {},
},
plugins: [require("@tailwindcss/line-clamp")],
}
Next, create an index.css
file in the src
folder and add this code:
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply antialiased text-green-900 bg-green-50;
font-family: "Gill Sans", "Gill Sans MT", Calibri, "Trebuchet MS", sans-serif;
}
#app {
@apply flex flex-col min-h-screen overflow-x-hidden;
}
In addition to importing the required Tailwind CSS classes, we’ve included a few CSS settings to help us define the default theme of our application. We’ve also implemented a flex layout system to help us create a sticky header and footer for our application.
We’ll need to import index.css
in src/main.js
:
import { createApp } from "vue"
import App from "./App.vue"
import "./index.css"
createApp(App).mount("#app")
Let’s now go ahead and define our application layout. First, clear out any existing components in src/components
. Next, within the same folder, create these three files:
Layout.vue
Header.vue
Footer.vue
Copy the following code for each file:
src/components/Footer.vue:
<template>
<footer
class="px-4 py-8 text-sm font-bold text-center text-green-100 bg-green-900">
<p class="text-sm tracking-wide">Copyright (c) 2021 SitePoint</p>
</footer>
</template>
src/components/Header.vue:
<template>
<header class="flex justify-center py-6 bg-green-900 place-items-center">
<img alt="Vue logo" src="../assets/logo.png" width="32" />
<span class="ml-4 text-lg font-bold text-green-100 md:text-xl">
Vue News | NYTimes Edition
</span>
</header>
</template>
src/components/Layout.vue:
<template>
<Header />
<main class="container flex-grow px-4 mx-auto my-12">
<slot />
</main>
<Footer />
</template>
<script>
import Header from "./Header.vue"
import Footer from "./Footer.vue"
export default {
components: {
Header,
Footer,
},
}
</script>
Finally, update src/App.vue
:
<template>
<Layout>
<p>Main content goes here</p>
</Layout>
</template>
<script>
import Layout from "./components/Layout.vue"
export default {
components: {
Layout,
},
}
</script>
Execute yarn dev
. The browser should refresh automatically.
With the application layout completed, we can now start building the core logic of our news app.
Building News App Components
Our application structure will be made up of three News components and one container, src/App.vue
. The container will be responsible for fetching post data and populating the components.
First, we need to design the layout and look for these components. Hence we need some mock data to begin with. Create the file src/posts.json
and populate it with the following data:
{
"posts": [
{
"title": "Stay Healthy When Exercising Outdoors",
"abstract": "Cold weather workouts do bring unique risks, but a little planning and preparation can help whether you’re going for a winter walk, trekking in snowshoes or sledding with the kids.",
"url": "https://www.nytimes.com/2021/02/06/at-home/exercise-outdoors-cold-weather.html",
"byline": "By Kelly DiNardo",
"published_date": "2021-02-06T23:40:05-05:00",
"thumbnail": "https://static01.nyt.com/images/2021/02/07/multimedia/07ah-OUTDOOREXERCISE/07ah-OUTDOOREXERCISE-mediumThreeByTwo210.jpg",
"caption": ""
},
{
"title": "4 Skiers Killed in Avalanche in Utah, Officials Say",
"abstract": "It was the third such deadly episode in days and the deadliest avalanche in the United States since May 2014, according to the authorities.",
"url": "https://www.nytimes.com/2021/02/06/us/avalanche-salt-lake-city.html",
"byline": "By Michael Levenson",
"published_date": "2021-02-06T20:22:39-05:00",
"thumbnail": "https://static01.nyt.com/images/2021/02/06/lens/06xp-avalanche-photo2/06xp-avalanche-photo2-mediumThreeByTwo210.jpg",
"caption": "A helicopter returning to Millcreek Canyon after rescuing one of the four avalanche survivors on Saturday."
}
]
}
I’d encourage you to duplicate the records in order to better test our component design layouts, but I won’t do that here due to space constraints.
Let’s now start building our News components. In the src/components
folder, create the following files:
NewsCard.vue
NewsList.vue
NewsFilter.vue
Just to visualize how all these components come together, import them in src/App.vue
and set them out as follows:
<template>
<Layout>
<h2 class="mb-8 text-4xl font-bold text-center capitalize">
News Section : <span class="text-green-700">{{ section }}</span>
</h2>
<NewsFilter v-model="section" />
<NewsList :posts="posts" />
</Layout>
</template>
<script>
import Layout from "./components/Layout.vue"
import NewsFilter from "./components/NewsFilter.vue"
import NewsList from "./components/NewsList.vue"
import data from "./posts.json"
export default {
components: {
Layout,
NewsFilter,
NewsList,
},
data() {
return {
section: "home",
posts: data.posts,
}
},
}
</script>
Let’s break down the code above:
- The
header
tag is where we display the current state value ofsection
. - The
NewsFilter
component will contain a dropdown input for users to select a different section. There will be a button they’ll have to click in order to perform the fetch. We’ve bound the component to the statesection
to allow state synchronization. - The
NewsList
component will display posts using theNewsCard
component across a responsive grid.
Let’s now start working with each individual News component. The NewsCard.vue
component will present data for a single post. It requires one prop, post
:
<template>
<section class="p-4 rounded-lg shadow-lg bg-gray-50 w-80">
<div class="h-96">
<a
class="text-xl font-bold text-center text-green-800 hover:text-green-600 hover:underline"
:href="post.url"
target="_blank"
rel="noreferrer"
>
{{ post.title }}
</a>
<img
class="w-full mt-2 rounded"
:src="post.thumbnail"
:alt="post.caption"
height="140"
width="210"
/>
<p class="mt-2 text-justify text-gray-700 line-clamp-4">
{{ post.abstract }}
</p>
</div>
<div>
<p class="mt-4 font-bold text-gray-600">{{ post.byline }}</p>
<p class="font-light text-gray-600">
{{ formatDate(post.published_date) }}
</p>
</div>
</section>
</template>
<script>
import { format } from "date-fns"
export default {
props: {
post: {
type: Object,
required: true,
},
},
methods: {
formatDate(strDate) {
return format(new Date(strDate), "MMMM do, yyyy")
},
},
}
</script>
The NewsList.vue
will loop through a posts array and populate NewsCards
across a responsive grid:
<template>
<div
class="grid grid-cols-1 gap-6 mt-4 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 justify-items-center"
>
<NewsCard v-for="(post, index) in posts" :key="index" :post="post" />
</div>
</template>
<script>
import NewsCard from "./NewsCard.vue"
export default {
props: {
posts: {
type: Array,
required: true,
},
},
components: {
NewsCard,
},
}
</script>
Next, we have the NewsFilter
component, which will allow users to load posts from different sections. First, we need a content file to store all the sections supported by the Top Stories API endpoint. Create the file src/components/sections.js
:
const sections = [
"home",
"arts",
"automobiles",
"books",
"business",
"fashion",
"food",
"health",
"insider",
"magazine",
"movies",
"nyregion",
"obituaries",
"opinion",
"politics",
"realestate",
"science",
"sports",
"sundayreview",
"technology",
"theater",
"magazine",
"travel",
"upshot",
"us",
"world",
]
export default sections
Let’s now create our NewsFilter.vue
, which contains a dropdown select input and a button. We’ll need to use v-model
to bind the state section
in a way that syncs up with the state in App.vue
:
<template>
<div class="flex justify-center p-4 rounded">
<!-- Start of select dropdown -->
<div class="relative inline-flex">
<svg
class="absolute top-0 right-0 w-2 h-2 m-4 pointer-events-none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 412 232"
>
<path
d="M206 171.144L42.678 7.822c-9.763-9.763-25.592-9.763-35.355 0-9.763 9.764-9.763 25.592 0 35.355l181 181c4.88 4.882 11.279 7.323 17.677 7.323s12.796-2.441 17.678-7.322l181-181c9.763-9.764 9.763-25.592 0-35.355-9.763-9.763-25.592-9.763-35.355 0L206 171.144z"
fill="#648299"
fill-rule="nonzero"
/>
</svg>
<select
class="h-10 pl-5 pr-10 text-gray-600 bg-white border border-gray-300 rounded-lg appearance-none hover:border-gray-400 focus:outline-none"
v-model="section"
>
<option
v-for="(section, index) in sections"
:key="index"
:value="section"
>
{{ capitalize(section) }}
</option>
</select>
</div>
<!-- End of select dropdown -->
<div class="self-center ml-8">
<button
class="px-6 py-2 text-white bg-green-700 rounded hover:bg-green-900"
>
Retrieve
</button>
</div>
</div>
</template>
<script>
import { computed } from "vue"
import sectionsData from "./sections"
export default {
props: {
modelValue: String,
},
setup(props, { emit }) {
const section = computed({
get: () => props.modelValue,
set: value => emit("update:modelValue", value),
})
return {
section,
}
},
data() {
return {
sections: sectionsData,
}
},
methods: {
capitalize(value) {
if (!value) return ""
value = value.toString()
return value.charAt(0).toUpperCase() + value.slice(1)
},
},
}
</script>
The article “Vue 3: Data down, Events up” best explains the strategy we are using to bind section
state to the NewsFilter
component. Basically, this allows child components to update props and sync with the parent component.
Below is a screenshot of the current state of the application:
Using Axios to Fetch Remote Data
Axios is a promise-based HTTP client for making Ajax requests, and will work great for our purposes. It provides a simple and rich API. It’s quite similar to the fetch
API, but without the need to add a polyfill for older browsers, and some other subtleties.
To install axios, run:
yarn add axios
The UI development of our application is complete. We now only need to implement remote fetching logic. Below is an example of the full URL format that NYTimes API service expects:
https://api.nytimes.com/svc/topstories/v2/home.json?api-key=your_api_key
First, let’s store our API key in a .env
file at the root of our project. Save in the following format:
VITE_NYT_API_KEY=####
Replace the hashes with your actual API key.
Since we’re using Vite, we need to comply with Vite’s manual on loading environment variables. Vue/CLI has its own instructions for doing the same.
Let’s now implement the logic that will fetch actual posts from the NYTimes REST API endpoint. Simply update src/App.vue
accordingly:
<template>
<Layout>
<h2 class="mb-8 text-4xl font-bold text-center capitalize">
News Section : <span class="text-green-700">{{ section }}</span>
</h2>
<NewsFilter v-model="section" :fetch="fetchNews" />
<NewsList :posts="posts" />
</Layout>
</template>
<script>
import Layout from "./components/Layout.vue"
import NewsFilter from "./components/NewsFilter.vue"
import NewsList from "./components/NewsList.vue"
import axios from "axios"
const api = import.meta.env.VITE_NYT_API_KEY
export default {
components: {
Layout,
NewsFilter,
NewsList,
},
data() {
return {
section: "home",
posts: [],
}
},
methods: {
// Helper function for extracting a nested image object
extractImage(post) {
const defaultImg = {
url: "http://placehold.it/210x140?text=N/A",
caption: post.title,
}
if (!post.multimedia) {
return defaultImg
}
let imgObj = post.multimedia.find(
media => media.format === "mediumThreeByTwo210"
)
return imgObj ? imgObj : defaultImg
},
async fetchNews() {
try {
const url = `https://api.nytimes.com/svc/topstories/v2/${this.section}.json?api-key=${api}`
const response = await axios.get(url)
const results = response.data.results
this.posts = results.map(post => ({
title: post.title,
abstract: post.abstract,
url: post.url,
thumbnail: this.extractImage(post).url,
caption: this.extractImage(post).caption,
byline: post.byline,
published_date: post.published_date,
}))
} catch (err) {
if (err.response) {
// client received an error response (5xx, 4xx)
console.log("Server Error:", err)
} else if (err.request) {
// client never received a response, or request never left
console.log("Network Error:", err)
} else {
console.log("Client Error:", err)
}
}
},
},
mounted() {
this.fetchNews()
},
}
</script>
Here we’ve created a function called fetchNews
that contains logic for performing the fetch logic. This function will be called from two places:
- The
mounted()
lifecycle event - The
NewsFilter
component
Let’s break the function down to make sure we understand what is going on:
- We’re using async syntax, since it’s cleaner than using regular
Promise
callback syntax. - Since we’re about to perform a network call, many things can go wrong. We’ve wrapped the function’s code within a
try...catch
block. Otherwise, users will be met with a non-descriptive Promise error if one occurs. - Using ES6 template literals, we’re able to build a URL string that automatically gets updated whenever the user changes the news
section
via theNewsFilter
component. Note that the API key has been included in the URL string as well. - After fetching results using the
axios.get()
function, we need to parse the results and format them in a way that’s compatible with our UI, specifically theNewsCard
component. We do this using JavaScript’sArray.map
function to create a new array with our formatted data. - Extracting image data is a little tricky. Some posts lack the
multimedia
field, and even if they do have it, there’s no guarantee that the media format we need is present. In such a case, we return a default image URL —http://placehold.it/210x140?text=N/A
— and use the post’s title as the caption. - In the error block, we’re checking the presence of specific error properties to determine what kind of error has occurred. You can use this information to construct a helpful error message.
Now, take a look at the template section and observe that we’ve included a new prop called fetch
, which links to the fetchNews
function. We need to update src/components/NewsFilter.vue
to accept this prop. Below, I’ve highlighted only the code sections you should change:
<template>
...
<button
class="px-6 py-2 text-white bg-green-700 rounded hover:bg-green-900"
@click="fetch"
>
Retrieve
</button>
...
</template>
<script>
export default {
props: {
modelValue: String,
fetch: Function,
},
}
</script>
You’ll probably need to restart the dev server in order to properly load the axios library and the API key. Once you’ve done that, you should have a view of actual posts. Below is the current state of the application.
You should be able to switch and load different news sections.
Final Touches and Demos
I decided to add some minor (optional) touches to make the app experience a little bit better, like introducing a loading image.
You can see a demo in the StackBlitz below (limited functionality):
Alternatively, you can view a live version here.
Conclusion
In this tutorial, we’ve learned how to start a Vue.js project from scratch, how to fetch data from an API using axios, and how to handle responses and manipulate data using components and computed properties.
Now we have a functional Vue.js 3.0 app, built around an API service. There are loads of improvements that could be made by plugging in some other APIs. For example, we could:
- Automatically queue social media posts from a category using the Buffer API
- Mark posts to be read later, using the Pocket API
… and so on.
The entire code for this project is also hosted on GitHub, so you can clone, run, and make any improvements you fancy.