DEV Community

Cover image for How To Build A Truck Tracker Custom Field and Map Widget for Strapi Admin
Theodore Kelechukwu Onyejiaku for Strapi

Posted on • Originally published at strapi.io

How To Build A Truck Tracker Custom Field and Map Widget for Strapi Admin

In this blog tutorial, we’ll walk through how to customize Strapi Admin panel by building a custom Truck Tracker plugin for Strapi 5. This plugin lets you manage delivery trucks from the admin panel, update their real-time location using a map, and display all trucks on a live dashboard widget.

Here’s what we’ll build:

  • A content type for storing truck details and GPS coordinates
  • A custom map input field (GeoPicker) so you can pick a truck’s location visually
  • A dashboard widget that shows all trucks on a map using React Leaflet
  • An API endpoint for updating positions from a GPS device or script
  • Logic to track when each truck's location was last updated

000-truck-truckers.png

Along the way, you’ll learn how to create a Strapi plugin and customize Strapi Admin panel, register custom fields, build admin widgets, and add secure backend routes with custom middleware.

This guide is great if you're looking to learn more about how Strapi works under the hood and want to build something practical with it.

You can also checkout out the video tutorial on YouTube that this blog post is baed on.


Let's get started by setting up a fresh Strapi project.

note: in the original tutorial, you were asked to clone the starter project, you can still do it based on the readme found here.

For this blog post I decided to start everything from scratch.

1. Setup the Strapi App

First, we need to setup the Strapi app. You can create one by running the following command:

npx create-strapi-app@latest delivery-app

# If you are using npx, you may be asked the following question:
Need to install the following packages:
create-strapi-app@5.15.1
Ok to proceed? (y) y

⠏
Enter fullscreen mode Exit fullscreen mode

You will be asked the following questions:

We will skip the Strapi Cloud login/signup step for now. But did you know Strapi Cloud now has a free plan?

🚀 Welcome to Strapi! Ready to bring your project to life?

Create a free account and get:
✨ 30 days of access to the Growth plan, which includes:
✅ Single Sign-On (SSO) login
✅ Content History
✅ Releases

? Please log in or sign up.
  Login/Sign up
❯ Skip
Enter fullscreen mode Exit fullscreen mode

You will be asked the following questions: I answered yes to all of them.

? Do you want to use the default database (sqlite) ? Yes
? Start with an example structure & data? Yes
? Start with Typescript? Yes
? Install dependencies with npm? Yes
? Initialize a git repository? Yes
Enter fullscreen mode Exit fullscreen mode

Once the app is created, you can start it by running the following command:

cd delivery-app
yarn dev
Enter fullscreen mode Exit fullscreen mode

Once the app is running, you can view the admin panel at http://localhost:1337/admin.

Go ahead and create your first Strapi Admin User.

001-strapi-admin-user.png

Once you are in, you will be greeted with the Strapi Dashboard screen:

002-strapi-dashboard.png

Now we can start working on our plugin.


2. Create the Plugin

We'll use Strapi's CLI to scaffold a new plugin called truck-tracker. This plugin will handle all truck tracking features.

npx @strapi/sdk-plugin init src/plugins/truck-tracker
Enter fullscreen mode Exit fullscreen mode

You will be asked the following questions:

✔ plugin name … truck-tracker
✔ plugin display name … Truck Tracker
✔ plugin description … Track Trucks!
✔ plugin author name … xxx
✔ plugin author email … xxx
✔ git url … xxx
✔ plugin license … MIT
✔ register with the admin panel? … yes
✔ register with the server? … yes
✔ use editorconfig? … yes
✔ use eslint? … yes
✔ use prettier? … yes
✔ use typescript? … yes
Enter fullscreen mode Exit fullscreen mode

After running the CLI, add the plugin to your config/plugins.ts:

export default () => ({
  "truck-tracker": {
    enabled: true,
    resolve: "src/plugins/truck-tracker",
  },
});
Enter fullscreen mode Exit fullscreen mode

Make sure you build the plugin, or run yarn watch to monitor it for changes. You can do so by running the following command in src/plugins/truck-tracker directory.

Just open a new terminal and run the following command:

If you are in the root directory of the project, let's change to the src/plugins/truck-tracker directory:

cd src/plugins/truck-tracker
Enter fullscreen mode Exit fullscreen mode

Now run the following command to build the plugin:

yarn build
yarn watch
Enter fullscreen mode Exit fullscreen mode

Make sure to restart the Strapi app if it is not running. If you view the Strapi admin, you should now see "Truck Tracker" in the sidebar.

003-strapi-truck-tracker.png


3. Create the Truck Content Type

We'll create a collection type for trucks that will store each truck's information and location. The schema includes:

  • identifier: A unique identifier for each truck (like a license plate number)
  • model: The truck's model, restricted to a predefined list of options
  • position: GPS coordinates stored as a JSON object with latitude and longitude
  • positionUpdatedAt: A timestamp for when the position was last updated
  • key: A private key used for secure position updates from GPS devices

Note that this content type will be referenced as plugin::truck-tracker.truck in the code, not api::truck.truck. This is because it's part of our plugin rather than the main API.

Create plugins/truck-tracker/server/src/content-types/truck.ts:

export default {
  schema: {
    kind: "collectionType",
    collectionName: "trucks",
    info: {
      singularName: "truck",
      pluralName: "trucks",
      displayName: "Delivery Truck",
      description: "",
    },
    // Specify where plugin-created content types are visible in the Strapi admin
    pluginOptions: {
      "content-manager": {
        visible: true,
      },
      "content-type-builder": {
        visible: false,
      },
    },
    attributes: {
      // how a truck identifies itself, like a license plate number
      identifier: {
        type: "string",
        required: true,
      },
      // model of truck
      model: {
        type: "enumeration",
        required: true,
        enum: [
          "Toyota Corolla",
          "Toyota RAV4",
          "Ford F-Series",
          "Honda CR-V",
          "Dacia Sandero",
        ],
      },
      // gps coordinates in the form { latitude, longitude }
      position: {
        type: "json",
        required: true,
      },
      // timestamp for when a truck was last updated
      positionUpdatedAt: {
        type: "datetime",
      },
      // password-like key for each truck to be able to update its position
      key: {
        type: "string",
        required: true,
        private: true,
      },
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

Add it to your plugin's content-types index:

import truck from "./truck";

export default {
  truck,
};
Enter fullscreen mode Exit fullscreen mode

Run yarn watch (and restart yarn develop if needed) and restart the Strapi to see the new content type in the admin.

Notice that we used the following options in the truck.ts file:

 pluginOptions: {
  'content-manager': {
    visible: true,
  },
  'content-type-builder': {
    visible: false,
  },
},

Enter fullscreen mode Exit fullscreen mode

This will make the truck content type visible in the Content Manager but not in the Content Type Builder.

004-strapi-truck-tracker-content-type.png


4. Add a GeoPicker for Admin Location Updates

The GeoPicker component will need to:

  • Shows a map centered on the current position
  • Allows clicking to set a new position
  • Displays the current latitude and longitude
  • Updates the truck's position in the database

First, let's create a basic text input version of the GeoPicker in plugins/truck-tracker/admin/src/components/GeoPicker.tsx:

import { Field, JSONInput } from "@strapi/design-system";
import React from "react";

// #region Types and Styles
interface GeoPickerProps {
  name: string;
  onChange: (event: {
    target: { name: string; value: object; type: string };
  }) => void;
  value?: object;
  intlLabel?: {
    defaultMessage: string;
  };
  required?: boolean;
}
// #endregion

const GeoPicker: React.FC<GeoPickerProps> = ({
  name,
  onChange,
  value,
  intlLabel,
  required,
}) => {
  // onChange is how we tell Strapi what the current value of our custom field is
  const handlePositionChange = (input: string) => {
    try {
      const value = JSON.parse(input);
      onChange({ target: { name, value, type: "json" } });
    } catch {
      // Handle invalid JSON
    }
  };

  const strValue = JSON.stringify(value, null, 2);

  return (
    <Field.Root name={name} required={required}>
      <Field.Label>{intlLabel?.defaultMessage ?? "Location"}</Field.Label>
      <JSONInput value={strValue} onChange={handlePositionChange}></JSONInput>
      <Field.Error />
      <Field.Hint />
    </Field.Root>
  );
};

export { GeoPicker };
Enter fullscreen mode Exit fullscreen mode

Register it in the plugin admin plugins/truck-tracker/admin/src/index.ts file:

import { PinMap } from '@strapi/icons';
import { GeoPicker } from './components/GeoPicker';

  register(app: StrapiApp) {
  // ...

    app.customFields.register({
      name: 'geo-picker',
      type: 'json',
      icon: PinMap,
      intlLabel: {
        id: 'custom.fields.geo-picker.label',
        defaultMessage: 'Geo Position',
      },
      intlDescription: {
        id: 'custom.fields.geo-picker.description',
        defaultMessage: 'Enter geographic coordinates',
      },
      components: {
        Input: () => ({ default: GeoPicker as React.ComponentType }) as any,
      },
    });

// ...
}
Enter fullscreen mode Exit fullscreen mode

The complete file should look like this:

import { getTranslation } from "./utils/getTranslation";
import { PLUGIN_ID } from "./pluginId";
import { Initializer } from "./components/Initializer";
import { PluginIcon } from "./components/PluginIcon";
import { PinMap } from "@strapi/icons";
import { GeoPicker } from "./components/GeoPicker";

export default {
  register(app: any) {
    app.addMenuLink({
      to: `plugins/${PLUGIN_ID}`,
      icon: PluginIcon,
      intlLabel: {
        id: `${PLUGIN_ID}.plugin.name`,
        defaultMessage: PLUGIN_ID,
      },
      Component: async () => {
        const { App } = await import("./pages/App");

        return App;
      },
    });

    app.customFields.register({
      name: "geo-picker",
      type: "json",
      icon: PinMap,
      intlLabel: {
        id: "custom.fields.geo-picker.label",
        defaultMessage: "Geo Position",
      },
      intlDescription: {
        id: "custom.fields.geo-picker.description",
        defaultMessage: "Enter geographic coordinates",
      },
      components: {
        Input: () => ({ default: GeoPicker as React.ComponentType } as any),
      },
    });

    app.registerPlugin({
      id: PLUGIN_ID,
      initializer: Initializer,
      isReady: false,
      name: PLUGIN_ID,
    });
  },

  async registerTrads({ locales }: { locales: string[] }) {
    return Promise.all(
      locales.map(async (locale) => {
        try {
          const { default: data } = await import(
            `./translations/${locale}.json`
          );

          return { data, locale };
        } catch {
          return { data: {}, locale };
        }
      })
    );
  },
};
Enter fullscreen mode Exit fullscreen mode

Register the custom field with the server in plugins/truck-tracker/server/src/register.ts:

// Register the custom field
strapi.customFields.register({
  name: "geo-picker",
  type: "json",
});
Enter fullscreen mode Exit fullscreen mode

Update the truck schema to use the custom field in plugins/truck-tracker/server/src/content-types/truck.ts:

  position: {
    type: 'customField',
    customField: 'global::geo-picker',
    required: true
  },
Enter fullscreen mode Exit fullscreen mode

The complete file should look like this:

export default {
  schema: {
    kind: "collectionType",
    collectionName: "trucks",
    info: {
      singularName: "truck",
      pluralName: "trucks",
      displayName: "Delivery Truck",
      description: "",
    },
    // Specify where plugin-created content types are visible in the Strapi admin
    pluginOptions: {
      "content-manager": {
        visible: true,
      },
      "content-type-builder": {
        visible: false,
      },
    },
    attributes: {
      // how a truck identifies itself, like a license plate number
      identifier: {
        type: "string",
        required: true,
      },
      // model of truck
      model: {
        type: "enumeration",
        required: true,
        enum: [
          "Toyota Corolla",
          "Toyota RAV4",
          "Ford F-Series",
          "Honda CR-V",
          "Dacia Sandero",
        ],
      },
      // gps coordinates in the form { latitude, longitude }
      position: {
        type: "customField",
        customField: "global::geo-picker",
        required: true,
      },
      // timestamp for when a truck was last updated
      positionUpdatedAt: {
        type: "datetime",
      },
      // password-like key for each truck to be able to update its position
      key: {
        type: "string",
        required: true,
        private: true,
      },
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

Once everything is done, back int the Strapi Admin, we should see the new custom field in the DeliveryTrucks content type.

005-strapi-truck-tracker-content-type-builder-with-custom-field.png

Now, let's enhance the GeoPicker with a map interface.

We'll use React Leaflet to let admins pick a truck's location on a map.

Install the dependencies (inside the plugin directory):

yarn add leaflet@1.9.4 react-leaflet@4.2.1
yarn add --dev @types/leaflet@1.9.4 @types/react-leaflet
Enter fullscreen mode Exit fullscreen mode

To add the map, go back to plugins/truck-tracker/admin/src/components/GeoPicker.tsx and paste in the same map code from before.

import { Box, Field, Flex, Typography } from "@strapi/design-system";
import React, { useState } from "react";
import { MapContainer, Marker, TileLayer, useMapEvents } from "react-leaflet";
import "leaflet/dist/leaflet.css";
import styled from "styled-components";

// #region Types and Styles
interface GeoPosition {
  latitude: number;
  longitude: number;
}

interface GeoPickerProps {
  name: string;
  onChange: (event: {
    target: { name: string; value: object; type: string };
  }) => void;
  value?: GeoPosition;
  intlLabel?: {
    defaultMessage: string;
  };
  required?: boolean;
}

interface MapEventsProps {
  onLocationSelected: (lat: number, lng: number) => void;
}

// Styles
const MapWrapper = styled.div`
  height: 400px;
  width: 100%;
  margin-bottom: 16px;

  .leaflet-container {
    z-index: 0;
    height: 100%;
    width: 100%;
    border-radius: 4px;
  }
`;
// #endregion

// Map Events Component
const MapEvents: React.FC<MapEventsProps> = ({ onLocationSelected }) => {
  useMapEvents({
    click: (e: any) => {
      onLocationSelected(e.latlng.lat, e.latlng.lng);
    },
  });

  return null;
};

// Default position (Paris)
const DEFAULT_POSITION: GeoPosition = {
  latitude: 48.8854611,
  longitude: 2.3284453,
};

const GeoPicker: React.FC<GeoPickerProps> = ({
  name,
  onChange,
  value,
  intlLabel,
  required,
}) => {
  const [position, setPosition] = useState<GeoPosition>(() => {
    try {
      return value ?? DEFAULT_POSITION;
    } catch {
      return DEFAULT_POSITION;
    }
  });

  // onChange is how we tell Strapi what the current value of our custom field is
  const handlePositionChange = (lat: number, lng: number) => {
    const newPosition = {
      latitude: lat,
      longitude: lng,
    };

    setPosition(newPosition);

    onChange({
      target: {
        name,
        value: newPosition,
        type: "json",
      },
    });
  };

  return (
    <Field.Root name={name} required={required}>
      <Field.Label>{intlLabel?.defaultMessage ?? "Location"}</Field.Label>
      <Box padding={4}>
        <MapWrapper>
          <MapContainer
            center={[position.latitude, position.longitude]}
            zoom={20}
            scrollWheelZoom
          >
            <TileLayer
              attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
              url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
            />
            <Marker position={[position.latitude, position.longitude]} />
            <MapEvents onLocationSelected={handlePositionChange} />
          </MapContainer>
        </MapWrapper>

        <Flex gap={4}>
          <Typography>Latitude: {position.latitude}</Typography>
          <Typography>Longitude: {position.longitude}</Typography>
        </Flex>
      </Box>
      <Field.Error />
      <Field.Hint />
    </Field.Root>
  );
};

export { GeoPicker };
Enter fullscreen mode Exit fullscreen mode

If you go to view the map in the Admin, you may see that the map appears broken, with none of the images displaying.

006-strapi-truck-tracker-map-broken.png

That's because Strapi has a security policy that prevents loading data from unknown external sources.

To fix this, you will need to update your Content Security Policy in the root of your Strapi project in config/middlewares.ts to include the domains required for the leaflet component.

  // replace 'strapi::security' with this object:
  {
    name: 'strapi::security',
    config: {
      contentSecurityPolicy: {
        useDefaults: true,
        directives: {
          'connect-src': ["'self'", 'https:'],
          'script-src': ["'self'", 'unsafe-inline', 'https://*.basemaps.cartocdn.com'],
          'media-src': [
            "'self'",
            'blob:',
            'data:',
            'https://*.basemaps.cartocdn.com',
            'https://tile.openstreetmap.org',
            'https://*.tile.openstreetmap.org',
          ],
          'img-src': [
            "'self'",
            'blob:',
            'data:',
            'https://*.basemaps.cartocdn.com',
            'market-assets.strapi.io',
            'https://*.tile.openstreetmap.org',
            'https://unpkg.com/leaflet@1.9.4/dist/images/',
          ],
        },
      },
    },
  },
Enter fullscreen mode Exit fullscreen mode

Try it again, and now it should display properly!

007-strapi-truck-tracker-map-fixed.png


5. Create a Widget to Display Truck Locations

We'll create a dashboard widget that shows all trucks on a map. This widget will:

  • Display a map centered on the average position of all trucks
  • Shows markers for each truck
  • Provides popups with truck information
  • Includes links to edit truck details
  • Updates automatically when truck positions change

First, let's create a basic widget with just a map (no trucks) in plugins/truck-tracker/admin/src/components/MapWidget.tsx:

import React from "react";
import { MapContainer, TileLayer } from "react-leaflet";
import "leaflet/dist/leaflet.css";
import styled from "styled-components";

// # region Types and Styles
// Styled components
const MapWrapper = styled.div`
  height: 100%;
  width: 100%;

  .leaflet-container {
    height: 100%;
    width: 100%;
    border-radius: 4px;
  }
`;
// #endregion

// Default position (Paris)
const DEFAULT_POSITION = [48.8854611, 2.3284453] as [number, number];

const MapWidget: React.FC = () => {
  return (
    <MapWrapper>
      <MapContainer center={DEFAULT_POSITION} zoom={20} scrollWheelZoom>
        <TileLayer
          attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
          url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
        />
      </MapContainer>
    </MapWrapper>
  );
};

export { MapWidget };
Enter fullscreen mode Exit fullscreen mode

Register the widget in plugins/truck-tracker/admin/src/index.ts:

// ...
import { PinMap, Globe } from "@strapi/icons";
import { MapWidget } from "./components/MapWidget";

app.widgets.register({
  icon: Globe,
  title: {
    id: `${PLUGIN_ID}.mywidget.title`,
    defaultMessage: "Trucks Live Tracker",
  },
  component: () => Promise.resolve(MapWidget),
  pluginId: PLUGIN_ID,
  id: "mywidget",
});
// ...
Enter fullscreen mode Exit fullscreen mode

The complete file should look like this:

import { getTranslation } from "./utils/getTranslation";
import { PLUGIN_ID } from "./pluginId";
import { Initializer } from "./components/Initializer";
import { PluginIcon } from "./components/PluginIcon";
import { Globe, PinMap } from "@strapi/icons";
import { GeoPicker } from "./components/GeoPicker";
import { MapWidget } from "./components/MapWidget";

export default {
  register(app: any) {
    app.addMenuLink({
      to: `plugins/${PLUGIN_ID}`,
      icon: PluginIcon,
      intlLabel: {
        id: `${PLUGIN_ID}.plugin.name`,
        defaultMessage: PLUGIN_ID,
      },
      Component: async () => {
        const { App } = await import("./pages/App");

        return App;
      },
    });

    app.customFields.register({
      name: "geo-picker",
      type: "json",
      icon: PinMap,
      intlLabel: {
        id: "custom.fields.geo-picker.label",
        defaultMessage: "Geo Position",
      },
      intlDescription: {
        id: "custom.fields.geo-picker.description",
        defaultMessage: "Enter geographic coordinates",
      },
      components: {
        Input: () => ({ default: GeoPicker as React.ComponentType } as any),
      },
    });

    app.widgets.register({
      icon: Globe,
      title: {
        id: `${PLUGIN_ID}.mywidget.title`,
        defaultMessage: "Trucks Live Tracker",
      },
      component: () => Promise.resolve(MapWidget),
      pluginId: PLUGIN_ID,
      id: "mywidget",
    });

    app.registerPlugin({
      id: PLUGIN_ID,
      initializer: Initializer,
      isReady: false,
      name: PLUGIN_ID,
    });
  },

  async registerTrads({ locales }: { locales: string[] }) {
    return Promise.all(
      locales.map(async (locale) => {
        try {
          const { default: data } = await import(
            `./translations/${locale}.json`
          );
          return { data, locale };
        } catch {
          return { data: {}, locale };
        }
      })
    );
  },
};
Enter fullscreen mode Exit fullscreen mode

If you go to the Strapi admin home page, you should now see the empty widget displayed.

008-strapi-truck-tracker-empty-widget.png

Now, let's add some hard-coded truck data to 'MapWidget.tsx' and see how it will look with Trucks:

import { Link } from "@strapi/design-system";
import React, { useEffect, useState } from "react";
import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet";
import "leaflet/dist/leaflet.css";
import styled from "styled-components";

// #region Types & Styles
interface Truck {
  identifier: string;
  documentId: string;
  name: string;
  model: string;
  position: {
    latitude: number;
    longitude: number;
  };
}

interface MapEventsProps {
  onLocationSelected: (latitude: number, longitude: number) => void;
}

// Styled components
const MapWrapper = styled.div`
  height: 100%;
  width: 100%;

  .leaflet-container {
    height: 100%;
    width: 100%;
    border-radius: 4px;
  }
`;

// #endregion

// Default position (Paris)
const DEFAULT_TRUCKS: Truck[] = [
  {
    documentId: "ABC",
    identifier: "123-ABC",
    position: { latitude: 48.8854611, longitude: 2.3284453 },
    name: "Bob",
    model: "Corolla",
  },
];

const MapWidget: React.FC<MapEventsProps> = () => {
  const [trucks] = useState<Truck[]>(DEFAULT_TRUCKS);
  const [zoom] = useState<number>(9);

  return (
    <MapWrapper>
      <MapContainer
        center={[48.8854611, 2.3284453]}
        zoom={zoom}
        scrollWheelZoom
      >
        <TileLayer
          attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
          url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
        />
        {trucks.map((truck) => (
          <TruckMarker key={truck.identifier} truck={truck} />
        ))}
      </MapContainer>
    </MapWrapper>
  );
};

// Individual truck marker component
const TruckMarker: React.FC<{ truck: Truck }> = ({ truck }) => {
  const { backendURL } = window.strapi as any;
  const href = `${backendURL}/admin/content-manager/collection-types/plugin::truck-tracker.truck/${truck.documentId}`;

  return (
    <Marker position={[truck.position.latitude, truck.position.longitude]}>
      <Popup className="request-popup">
        <h1 style={{ fontWeight: "bold", fontSize: "1.5rem" }}>{truck.name}</h1>
        <p style={{ fontSize: "1rem" }}>{truck.model}</p>
        <Link href={href} target="_blank">
          Open in content manager
        </Link>
      </Popup>
    </Marker>
  );
};

export { MapWidget };
Enter fullscreen mode Exit fullscreen mode

Check the homepage again to see how it looks. Now you should see Bob's truck on the map!

009-strapi-truck-tracker-widget-with-trucks.png

6. Create an Admin Route to Get Truck Info

To provide the actual truck data to the widget, we will need to add an admin API route.

Create a truck controller at plugins/truck-tracker/server/src/controllers/truck.ts

import { Core } from "@strapi/strapi";

const truck = ({ strapi }: { strapi: Core.Strapi }): Core.Controller => ({
  async getTruckPositions(ctx) {
    const trucks = await strapi
      .documents("plugin::truck-tracker.truck")
      // Only select the necessary fields in the query
      .findMany({
        fields: ["identifier", "model", "position", "positionUpdatedAt"],
      });

    return ctx.send(trucks);
  },
});

export default truck;
Enter fullscreen mode Exit fullscreen mode

Export the controller from the plugins/truck-tracker/server/src/controllers/index.ts file:

import controller from "./controller";
import truck from "./truck";

export default {
  controller,
  truck,
};
Enter fullscreen mode Exit fullscreen mode

Create file plugins/truck-tracker/server/src/routes/admin-api.ts:

export default [
  {
    method: "GET",
    // this will appear at localhost:1337/truck-tracker/truck-positions
    path: "/truck-positions",
    handler: "truck.getTruckPositions",
    config: {
      // in the real world, you may want to add a custom policy
      policies: ["admin::isAuthenticatedAdmin"],
    },
  },
];
Enter fullscreen mode Exit fullscreen mode

In plugin/truck-tracker/server/src/routes/index.ts we need to add the admin routes:

import contentAPIRoutes from "./content-api";
import adminAPIRoutes from "./admin-api";

const routes = {
  "content-api": {
    type: "content-api",
    routes: contentAPIRoutes,
  },
  "admin-api": {
    type: "admin",
    routes: adminAPIRoutes,
  },
};

export default routes;
Enter fullscreen mode Exit fullscreen mode

7. Call the Admin Route from the Widget

Update the MapWidget component to fetch and display truck data:

import { Link } from "@strapi/design-system";
import React, { useEffect, useState } from "react";
import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet";
import "leaflet/dist/leaflet.css";
import styled from "styled-components";
import { useFetchClient } from "@strapi/strapi/admin";

// #region Types & Styles
interface Truck {
  identifier: string;
  documentId: string;
  name: string;
  model: string;
  position: {
    latitude: number;
    longitude: number;
  };
}

interface MapEventsProps {
  onLocationSelected: (latitude: number, longitude: number) => void;
}

// Styled components
const MapWrapper = styled.div`
  height: 100%;
  width: 100%;

  .leaflet-container {
    height: 100%;
    width: 100%;
    border-radius: 4px;
  }
`;

// #endregion

// Default position (Paris)
const DEFAULT_TRUCKS: Truck[] = [
  {
    documentId: "ABC",
    identifier: "123-ABC",
    position: { latitude: 48.8854611, longitude: 2.3284453 },
    name: "Test Truck Bob",
    model: "Corolla",
  },
];

const MapWidget: React.FC<MapEventsProps> = () => {
  const [trucks, setTrucks] = useState<Truck[]>(DEFAULT_TRUCKS);
  const [zoom] = useState<number>(9);

  // this ensure the front-end request includes Strapi auth headers
  const { get } = useFetchClient();

  useEffect(() => {
    const fetchTruckPositions = async () => {
      try {
        const { data } = await get("/truck-tracker/truck-positions");

        setTrucks(data);
      } catch (error) {
        console.error("Error fetching truck positions:", error);
      }
    };

    fetchTruckPositions().then();
  }, []);

  return (
    <MapWrapper>
      <MapContainer
        center={[48.8854611, 2.3284453]}
        zoom={zoom}
        scrollWheelZoom
      >
        <TileLayer
          attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
          url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
        />
        {trucks.map((truck) => (
          <TruckMarker key={truck.identifier} truck={truck} />
        ))}
      </MapContainer>
    </MapWrapper>
  );
};

// Individual truck marker component
const TruckMarker: React.FC<{ truck: Truck }> = ({ truck }) => {
  const { backendURL } = window.strapi as any;
  const href = `${backendURL}/admin/content-manager/collection-types/plugin::truck-tracker.truck/${truck.documentId}`;

  return (
    <Marker position={[truck.position.latitude, truck.position.longitude]}>
      <Popup className="request-popup">
        <h1 style={{ fontWeight: "bold", fontSize: "1.5rem" }}>{truck.name}</h1>
        <p style={{ fontSize: "1rem" }}>{truck.model}</p>
        <Link href={href} target="_blank">
          Open in content manager
        </Link>
      </Popup>
    </Marker>
  );
};

export { MapWidget };
Enter fullscreen mode Exit fullscreen mode

Take a look at the admin and check that it's working!

In the content manager go ahead and add couple of trucks.

010-strapi-truck-tracker-content-manager-with-trucks.gif

You should be able to see the trucks in our Map Widget at the homepage.


8. Create Endpoint for GPS Device

Now we also need to handle getting data into the system.

We'll create a secure endpoint that allows GPS devices to update truck positions. This endpoint:

  • Accepts POST requests with truck identifier and coordinates
  • Verifies the truck exists
  • Updates only the position data
  • Returns the updated position and timestamp

For security, we'll add a policy that verifies a secret key for each truck. This ensures that only authorized devices can update positions. In a production environment, you might use a more sophisticated authentication method like TOTP (Time-based One-Time Password).

In plugins/truck-tracker/server/src/controllers/controller.ts:


// add : Core.Controller type to controller types to get a fully typed method
const controller = ({ strapi }: { strapi: Core.Strapi }): Core.Controller => ({

 // ...

  async updateTruckPosition(ctx) {
    const { identifier, latitude, longitude } = ctx.request.body;

    // Get the truck
    const truck = await strapi.documents('plugin::truck-tracker.truck').findFirst({
      filters: { identifier },
    });

    if (!truck) {
      return ctx.notFound('Truck not found');
    }

    const updatedTruckPosition = await strapi.documents('plugin::truck-tracker.truck').update({
      documentId: truck.documentId,
      data: {
        position: {
          latitude,
          longitude,
        },
      } as any,
    });

    return {
      data: {
        identifier: updatedTruckPosition.identifier,
        position: updatedTruckPosition.position,
        positionUpdatedAt: updatedTruckPosition.positionUpdatedAt,
      },
    };
  },

// ...
Enter fullscreen mode Exit fullscreen mode

The complete file should look like this:

import type { Core } from "@strapi/strapi";

const controller = ({ strapi }: { strapi: Core.Strapi }): Core.Controller => ({
  index(ctx) {
    ctx.body = strapi
      .plugin("truck-tracker")
      // the name of the service file & the method.
      .service("service")
      .getWelcomeMessage();
  },

  async updateTruckPosition(ctx) {
    const { identifier, latitude, longitude } = ctx.request.body;

    // Get the truck
    const truck = await strapi
      .documents("plugin::truck-tracker.truck")
      .findFirst({
        filters: { identifier },
      });

    if (!truck) {
      return ctx.notFound("Truck not found");
    }

    const updatedTruckPosition = await strapi
      .documents("plugin::truck-tracker.truck")
      .update({
        documentId: truck.documentId,
        data: {
          position: {
            latitude,
            longitude,
          },
        } as any,
      });

    return {
      data: {
        identifier: updatedTruckPosition.identifier,
        position: updatedTruckPosition.position,
        positionUpdatedAt: updatedTruckPosition.positionUpdatedAt,
      },
    };
  },
});

export default controller;
Enter fullscreen mode Exit fullscreen mode

In plugins/truck-tracker/server/src/routes/content-api.ts add the following route:

  {
    method: "POST",
    path: "/update-position",
    // name of the controller file & the method.
    handler: "controller.updateTruckPosition",
    config: {
      policies: [],
      auth: false,
    },
    auth: false,
  },
Enter fullscreen mode Exit fullscreen mode

The complete file should look like this:

export default [
  {
    method: "GET",
    path: "/",
    // name of the controller file & the method.
    handler: "controller.index",
    config: {
      policies: [],
    },
  },

  {
    method: "POST",
    path: "/update-position",
    // name of the controller file & the method.
    handler: "controller.updateTruckPosition",
    config: {
      policies: [],
      auth: false,
    },
    auth: false,
  },
];
Enter fullscreen mode Exit fullscreen mode

I kept the example route for reference, but we don't need it.

To simulate the GPS device, we'll create a simple script that updates the truck position.

In the root of the project in the scripts folder add the following script update-truck-position.ts:

interface UpdatePositionArgs {
  identifier: string;
  latitude: number;
  longitude: number;
  key: string;
}

interface ApiResponse {
  message?: string;
  error?: {
    status: number;
    name: string;
    message: string;
    details?: any;
  };
  [key: string]: any;
}

export async function updateTruckPosition({
  identifier,
  latitude,
  longitude,
  key,
}: UpdatePositionArgs) {
  try {
    const url = "http://localhost:1337/api/truck-tracker/update-position";
    const requestData = {
      identifier,
      latitude,
      longitude,
      key,
    };

    console.log("Sending request to:", url);
    console.log("Request data:", JSON.stringify(requestData, null, 2));

    const response = await fetch(url, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(requestData),
    });

    const data = (await response.json()) as ApiResponse;

    if (!response.ok) {
      console.error("Full error response:", JSON.stringify(data, null, 2));
      throw new Error(
        data.error?.message || data.message || "Failed to update position"
      );
    }

    console.log("Position updated successfully:", data);
  } catch (error) {
    console.error(
      "Error updating position:",
      error instanceof Error ? error.message : error
    );
  }
}

// Run if called directly
if (require.main === module) {
  const args = process.argv.slice(2);

  if (args.length !== 4) {
    console.error(
      "Usage: ts-node update-truck-position.ts <identifier> <latitude> <longitude> <key>"
    );
    process.exit(1);
  }

  const [identifier, latitude, longitude, key] = args;

  updateTruckPosition({
    identifier,
    latitude: parseFloat(latitude),
    longitude: parseFloat(longitude),
    key,
  }).catch((error) => {
    console.error("Script failed:", error);
    process.exit(1);
  });
}
Enter fullscreen mode Exit fullscreen mode

Create a truck in the admin with identifier 'ABC' and key '123', and test it out:

011-strapi-truck-tracker-truck-in-admin.png

npx ts-node ./scripts/update-truck-position.ts ABC 52.4 13.4 123
Enter fullscreen mode Exit fullscreen mode

You should see the following in the console:

Sending request to: http://localhost:1337/api/truck-tracker/update-position
Request data: {
  "identifier": "ABC",
  "latitude": 52.4,
  "longitude": 13.4,
  "key": "123"
}
Position updated successfully: {
  data: {
    identifier: 'ABC',
    position: { latitude: 52.4, longitude: 13.4 },
    positionUpdatedAt: null
  }
}
Enter fullscreen mode Exit fullscreen mode

We were able to update the truck position. Nice!

9. Add Custom Policy to Verify the Key

We'll add a policy to secure the position update endpoint. This policy:

  • Extracts the truck identifier and key from the request
  • Looks up the truck in the database
  • Verifies that the provided key matches the truck's key
  • Only allows the update if the key is correct

This provides a simple but effective security layer. You can test it by trying to update a position with both correct and incorrect keys.

In plugins/truck-tracker/server/src/policies/index.ts replace the file with the following:

import { Core } from "@strapi/strapi";

export default {
  "verify-truck-key": async (
    policyContext: Core.PolicyContext,
    _config: unknown,
    { strapi }: { strapi: Core.Strapi }
  ) => {
    const { identifier, key } = policyContext.request.body;

    const truck = await strapi
      .documents("plugin::truck-tracker.truck")
      .findFirst({
        filters: { identifier },
      });

    return truck?.key === key;
  },
};
Enter fullscreen mode Exit fullscreen mode

Add it to the route found in plugins/truck-tracker/server/src/routes/content-api.ts add the following for policy:

// ...
  config: {
    policies: ["verify-truck-key"],
    auth: false,
  },
Enter fullscreen mode Exit fullscreen mode

The complete file should look like this:

export default [
  {
    method: "GET",
    path: "/",
    // name of the controller file & the method.
    handler: "controller.index",
    config: {
      policies: [],
    },
  },

  {
    method: "POST",
    path: "/update-position",
    // name of the controller file & the method.
    handler: "controller.updateTruckPosition",
    config: {
      policies: ["verify-truck-key"],
      auth: false,
    },
    auth: false,
  },
];
Enter fullscreen mode Exit fullscreen mode

Test with a wrong key (should fail):

npx ts-node ./scripts/update-truck-position.ts ABC 52.4 13.4 wrong
Enter fullscreen mode Exit fullscreen mode

You should see the following in the console:

Sending request to: http://localhost:1337/api/truck-tracker/update-position
Request data: {
  "identifier": "ABC",
  "latitude": 52.4,
  "longitude": 13.4,
  "key": "wrong"
}
Full error response: {
  "data": null,
  "error": {
    "status": 403,
    "name": "PolicyError",
    "message": "Policy Failed",
    "details": {}
  }
}
Error updating position: Policy Failed
Enter fullscreen mode Exit fullscreen mode

And with the correct key (should succeed):

npx ts-node ./scripts/update-truck-position.ts ABC 52.4 13.4 123
Enter fullscreen mode Exit fullscreen mode

You should see the following in the console:

Sending request to: http://localhost:1337/api/truck-tracker/update-position
Request data: {
  "identifier": "ABC",
  "latitude": 52.4,
  "longitude": 13.4,
  "key": "123"
}
Position updated successfully: {
  data: {
    identifier: 'ABC',
    position: { latitude: 52.4, longitude: 13.4 },
    positionUpdatedAt: null
  }
}
Enter fullscreen mode Exit fullscreen mode

10. Add Document Service Middleware

We'll add middleware to automatically update the positionUpdatedAt timestamp. This middleware:

  • Triggers only when a truck's position is updated
  • Compares the new position with the old one
  • Updates the timestamp only if the position actually changed
  • Works for both admin updates and GPS device updates

You can learn more about Document Service Middleware here.

This optimization ensures that the timestamp only updates when necessary, making it more accurate for tracking position changes.

In plugins/truck-tracker/server/src/register.ts:

import type { Core } from "@strapi/strapi";

interface Position {
  latitude: number;
  longitude: number;
}

interface TruckData {
  position?: Position;
  positionUpdatedAt?: string;
}

const register = ({ strapi }: { strapi: Core.Strapi }) => {
  // Register the custom field
  strapi.customFields.register({
    name: "geo-picker",
    type: "json",
  });

  strapi.documents.use(async (context, next) => {
    if (
      context.uid === "plugin::truck-tracker.truck" &&
      context.action === "update"
    ) {
      const { data } = context.params as { data: TruckData };

      const originalData = (await strapi
        .documents("plugin::truck-tracker.truck")
        .findOne({ documentId: context.params.documentId })) as TruckData;

      const { position: newPos } = data;
      const { position: oldPos } = originalData;

      // Only update if coordinates have actually changed
      if (
        newPos?.latitude !== oldPos?.latitude ||
        newPos?.longitude !== oldPos?.longitude
      ) {
        data.positionUpdatedAt = new Date().toISOString();
      }
    }

    return next();
  });
};

export default register;
Enter fullscreen mode Exit fullscreen mode

Make sure to rebuild the plugin:

yarn build
yarn watch
Enter fullscreen mode Exit fullscreen mode

Demonstrate that the position timestamp now updates when you save in the admin AND when you run the update script… but not when the position stays the same.

Try running the script again with the same position (should not update the timestamp):

npx ts-node ./scripts/update-truck-position.ts ABC 52.4 13.4 123
Enter fullscreen mode Exit fullscreen mode

You should see the following in the console: Since the position is the same, the timestamp should not update.

Position updated successfully: {
  data: {
    identifier: 'ABC',
    position: { latitude: 52.4, longitude: 13.4 },
    positionUpdatedAt: '2025-06-16T18:22:11.353Z'
  }
}
Enter fullscreen mode Exit fullscreen mode

positionUpdatedAt: '2025-06-16T18:22:11.353Z' should not change.

And if you update the position, the timestamp should change.

npx ts-node ./scripts/update-truck-position.ts ABC 52.4 13.5 123 
Enter fullscreen mode Exit fullscreen mode

You should see the following in the console: Since the position is different, the timestamp should update.

Position updated successfully: {
  data: {
    identifier: 'ABC',
    position: { latitude: 52.4, longitude: 13.5 },
    positionUpdatedAt: '2025-06-16T18:34:25.156Z'
  }
}
Enter fullscreen mode Exit fullscreen mode

Testing Your Truck Tracker

Now that you've built the complete truck tracker plugin, let's test it:

  1. Create a new truck in the admin panel

    • Set an identifier (like "TRUCK-001")
    • Choose a model from the dropdown
    • Set a position using the map
    • Save the truck
  2. View the truck on the dashboard

    • Go to the Strapi dashboard
    • Add the "Trucks Live Tracker" widget
    • You should see your truck on the map
  3. Update the truck's position

    • Use the GPS device endpoint to update the position
    • The widget should automatically update to show the new position
    • The positionUpdatedAt timestamp should only update when the position changes

Conclusion

That’s it—you just built a full truck tracking system in Strapi! 🚚💨 and in the process learned how to customize Strapi admin panel. Nice!

You can now add trucks, set their location on a map, and see their positions update in real time from a device or script. You even added some smart features like secure updates and automatic time stamping.

There’s a lot more you could build from here—like showing truck history, sending alerts when they stop moving, or syncing with an outside GPS service.

Hope you had fun building this! Let us know what you create next.

Project Repo

Join the Strapi community: Come hang out with us during our "Open Office" hours on Discord.

We are there Monday through Friday from 12:30pm CST time.

Stop on by to chat, ask questions, or just say hi!

Top comments (0)