Fetching and storing activities from Garmin Connect with Strapi and visualizing them with NextJS


With getting into the IndieWeb i started to reflect about myself and how i can actually own my data instead of giving them to so called silos.

Due to the fact I am a passionate (mountain) bike rider i was thinking about how i could use tracking/activity apps in a way to get my data back, because obviously when i am going for a ride with my bike and i am tracking the route with strava and/or komoot the data is then saved by them. Considering that every route is tracked on a Garmin device anyway and then synchronised to the apps i decided to have a look at the Garmin Connect/Activity API.

Unfortunately the official Garmin Activity API is only available for approved business developer.

But after some searching i found the npm-package garmin-connect which allows you to connect to Garmin Connect for sending and receiving activity data.

Fetching and storing activities from Garmin Connect with Strapi

You can install the package with

npm install garmin-connect


yarn add garmin-connect

and use it like

const { GarminConnect } = require('garmin-connect');
// Create a new Garmin Connect Client
const GCClient = new GarminConnect();
// Uses credentials from garmin.config.json or uses supplied params
await GCClient.login('my.email@example.com', 'MySecretPassword');
const userInfo = await GCClient.getActivities());

I stored the email and the password for the login in environment variables and used them with

const { GarminConnect } = require('garmin-connect');
const GCClient = new GarminConnect();
await GCClient.login(process.env.GARMIN_EMAIL ,process.env.GARMIN_PWD);
const userInfo = await GCClient.getActivities());

Afterwards experimented a bit with the Garmin Connect and found out there are some very low limits. Approximately after ~50 requests in one minute couldn't get anymore any data and had to wait for some (maybe one?; i am not sure) hours until the request was successful again.

In general you can probably do way more with Garmin Connect as you will need, like for example:

  • Get user info
  • Get social user info
  • Get heart rate
  • Set body weight
  • Get list of workouts
  • Add new workouts
  • Add workouts to you calendar
  • Remove previously added workouts
  • Get list of activities
  • Get details about one specific activity
  • Get the step count

I used only GCClient.getActivities(); to get all activities and and GCClient.getActivity({ activityId: id }); to get the details of the activity (like spatial data representing the route, start-point and end-point).

To be able to store the data in Strapi i created a new content type collection activities with the following fields/attributes:


Afterwards new entrys for activities can be created.

Strapi has a documention which explains how to fetch external data and create entries with it: Fetching external data.

To get the data from Garmin Connect into Strapi i created a function getGarminConnectActivities.js for Strapi (/config/functions/getGarminConnectActivities.js)

The function will take care of:

  • getting existing activities in Strapi,
  • get recent activites from Garmin Connect,
  • checking if an activity already exists in Strapi,
  • creating the activity in Strapi if it doesn't exist yet and also get the details for it.

and basically looks like this:

module.exports = async () => {
  await GCClient.login(process.env.GARMIN_USERNAME, process.env.GARMIN_PWD)
  const activities = await GCClient.getActivities()
  const exisitingActivities = await getExistingActivities()
  activities ? activities.map((activity) => {
    const isExisting = exisitingActivities.includes(activity.activityId)
    isExisting ? console.log(activity.activityId + " already exists") : createEntry(activity)
  : console.log("no activities found")

After all activities from Garmin are fetched, i am mapping through them to

  • check if they already exist in my cms
  • and create them if needed

The exisiting activities in my CMS are fetched with

const getExistingActivities = async () => {
  const existingActivityIds = []
  const activities = await axios.get(`https://strapi.url/activities`)
  activities.data.map((activity) => {
  return existingActivityIds

and the activityIds (originally from Garmin Connect) are returned to be able to check if an entry already exists. If the entry doesn't exist, details for the missing activity are fetched and a new entry is created with:

const createEntry = async (activity) => {
  const details = await GCClient.getActivity({ activityId: activity.activityId });
  await strapi.query('activity').create({
    activityID: activity.activityId,
    activityName: activity.activityName,
    beginTimestamp: activity.beginTimestamp,
    activityType: activity.activityType,
    distance: activity.distance,
    duration: activity.duration,
    elapsedDuration:  activity.elapsedDuration,
    movingDuration: activity.movingDuration,
    elevationGain: activity.elevationGain,
    elevationLoss: activity.elevationLoss,
    minElevation: activity.minElevation,
    maxElevation: activity.minElevation,
    sportTypeId: activity.sportTypeId,
    averageSpeed: activity.averageSpeed * 3.6 //(m/s -> km/h),
    maxSpeed: activity.maxSpeed * 3.6 //(m/s -> km/h),,
    startLatitude: activity.startLatitude,
    startLongitude: activity.startLongitude,
    endLatitude: activity.endLatitude,
    endLongitude: activity.endLongitude,
    details: details

You can save way more but i tried to cut it down to the ones i really need or eventually will need.

Only thing missing is some automatic triggering. For this you can use cron jobs in Strapi (/config/functions/cron.js).

module.exports = {
// Add your own logic here (e.g. send a queue of email, create a database backup, etc.).

    '0 0 18 * * *': () => {

I decied to trigger the function everyday at 6 pm so i can have a look at my activites in the evening. 😎

Thats the fun from the 'backend part'.

Next step is to visualize the data in NextJS.

Visualizing the data with NextJS

I am really liking the embeddable tours of komoot optic-wise, so i decided to create a similiar looking option for the preview of my activities in the posts-feed and activites-feed.

So the preview should consist of

  • an title of the activitie (+datetime),
  • an symbol displaying the activity type (cycling, running, etc.)
  • some basic metrics like distance, duration and average speed,
  • a map displaying the line of the route with osm-data and and aerial-view,
  • and optionally a graph displaying the elevation along the track (still in work).

The component looks like this at the moment: activity-post-preview.png


In the activityType object you can find typeId which correlates to the type of the activity, e.g. cycling, running etc. I created a small function which will return a icon from react-icons visualizing the activity type.

import { FaRunning, FaBiking } from 'react-icons/fa';

const getTypeIcon = activity => {
    if (activity.activityType.typeId == 5) {
        return <FaBiking/>
    } else if (activity.activityType.typeId == 15) {
      return <FaRunning/>


Due to the fact the duration is given in seconds and i wanted it to display like 1h 10m 12s there is also a need for a workaround which looks like the following:

const secondsToHms = (s) => {
    const hours = (((s - s % 3600) / 3600) % 60)
    const minutes = (((s - s % 60) / 60) % 60)  
    const seconds = (s % 60)  
    return (`${hours}h ${minutes}min ${seconds}s`)

Map (react-leaflet)

Then i created a small map with react-leaflet displaying

  • the track as polyline,
  • the start point and
  • the end point.

Therefore i created a new map-component:

import React, { useEffect, useState } from "react"
import { Marker, MapContainer, TileLayer, LayersControl, Polyline } from "react-leaflet";

const Map = (data) => {
  const geo = data.data
  const style= { 
    color: '#11a9ed',
    weight: "5"

  const bounds = [[geo.maxLat, geo.maxLon], [geo.minLat, geo.minLon]]
  return (
      style={{ height: "500px", width: "100%" }}
    <LayersControl position="topright">
      <LayersControl.BaseLayer checked name="OpenStreetMap.Mapnik">
          attribution ='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>'

        <LayersControl.BaseLayer name="Esri World Imagery">
            attribution='Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP'

        <Marker id="start" position={geo.startPoint}/>
        <Polyline pathOptions={style} positions={geo.polyline} />
        <Marker id="end" position={geo.endPoint}/>


export default Map;

Luckily the data from the Garmin Connect has already exactly the structure we need to create the map, which are the coordinates for the polyline and the two points.

The coordinates can be found in geoPolylineDTO in the activitydetails


With maxLat, maxLon, minLat and minLon i created the bounds which will set the default view for the map when passed to the MapContainer.

const bounds = [[geo.maxLat, geo.maxLon], [geo.minLat, geo.minLon]]

      style={{ height: "200px", width: "100%" }}

Then i added the LayerControl to be able to toggle between the two Tilelayers

  • OSM (Carto) and
  • Aerial Image (Esri)

After that i just created two markers and a polyline with the exisiting objects startPoint, endPoint and polyline.

        <Marker id="start" position={geo.startPoint}/>
        <Polyline pathOptions={style} positions={geo.polyline} />
        <Marker id="end" position={geo.endPoint}/>

You can find several other tilelayers in leaflet-providers-preview.

For an example of the preview head over to /activities. You can find the code for the activity-preview in my github-repositiory.

For the actual post of the activity i made the map a bit larger and added some more metrics for now.

(osm) activity-post.png

(Aerial view) activity-post-aerial.png

  1. Copy

0 Webmentions

What’s this?

Have you published a response to this? Send me a webmention by letting me know the URL.

Found no Webmentions yet. Be the first!