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:
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.
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
⠏
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
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
Once the app is created, you can start it by running the following command:
cd delivery-app
yarn dev
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.
Once you are in, you will be greeted with the Strapi Dashboard screen:
Now we can start working on our 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
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
After running the CLI, add the plugin to your config/plugins.ts
:
1export default () => ({
2 "truck-tracker": {
3 enabled: true,
4 resolve: "src/plugins/truck-tracker",
5 },
6});
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
Now run the following command to build the plugin:
yarn build
yarn watch
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.
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 optionsposition
: GPS coordinates stored as a JSON object with latitude and longitudepositionUpdatedAt
: A timestamp for when the position was last updatedkey
: A private key used for secure position updates from GPS devicesNote 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
:
1export default {
2 schema: {
3 kind: "collectionType",
4 collectionName: "trucks",
5 info: {
6 singularName: "truck",
7 pluralName: "trucks",
8 displayName: "Delivery Truck",
9 description: "",
10 },
11 // Specify where plugin-created content types are visible in the Strapi admin
12 pluginOptions: {
13 "content-manager": {
14 visible: true,
15 },
16 "content-type-builder": {
17 visible: false,
18 },
19 },
20 attributes: {
21 // how a truck identifies itself, like a license plate number
22 identifier: {
23 type: "string",
24 required: true,
25 },
26 // model of truck
27 model: {
28 type: "enumeration",
29 required: true,
30 enum: [
31 "Toyota Corolla",
32 "Toyota RAV4",
33 "Ford F-Series",
34 "Honda CR-V",
35 "Dacia Sandero",
36 ],
37 },
38 // gps coordinates in the form { latitude, longitude }
39 position: {
40 type: "json",
41 required: true,
42 },
43 // timestamp for when a truck was last updated
44 positionUpdatedAt: {
45 type: "datetime",
46 },
47 // password-like key for each truck to be able to update its position
48 key: {
49 type: "string",
50 required: true,
51 private: true,
52 },
53 },
54 },
55};
Add it to your plugin's content-types index:
1import truck from "./truck";
2
3export default {
4 truck,
5};
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:
1 pluginOptions: {
2 'content-manager': {
3 visible: true,
4 },
5 'content-type-builder': {
6 visible: false,
7 },
8},
This will make the truck content type visible in the Content Manager but not in the Content Type Builder.
The GeoPicker component will need to:
First, let's create a basic text input version of the GeoPicker in plugins/truck-tracker/admin/src/components/GeoPicker.tsx
:
1import { Field, JSONInput } from "@strapi/design-system";
2import React from "react";
3
4// #region Types and Styles
5interface GeoPickerProps {
6 name: string;
7 onChange: (event: {
8 target: { name: string; value: object; type: string };
9 }) => void;
10 value?: object;
11 intlLabel?: {
12 defaultMessage: string;
13 };
14 required?: boolean;
15}
16// #endregion
17
18const GeoPicker: React.FC<GeoPickerProps> = ({
19 name,
20 onChange,
21 value,
22 intlLabel,
23 required,
24}) => {
25 // onChange is how we tell Strapi what the current value of our custom field is
26 const handlePositionChange = (input: string) => {
27 try {
28 const value = JSON.parse(input);
29 onChange({ target: { name, value, type: "json" } });
30 } catch {
31 // Handle invalid JSON
32 }
33 };
34
35 const strValue = JSON.stringify(value, null, 2);
36
37 return (
38 <Field.Root name={name} required={required}>
39 <Field.Label>{intlLabel?.defaultMessage ?? "Location"}</Field.Label>
40 <JSONInput value={strValue} onChange={handlePositionChange}></JSONInput>
41 <Field.Error />
42 <Field.Hint />
43 </Field.Root>
44 );
45};
46
47export { GeoPicker };
Register it in the plugin admin plugins/truck-tracker/admin/src/index.ts
file:
1import { PinMap } from '@strapi/icons';
2import { GeoPicker } from './components/GeoPicker';
3
4 register(app: StrapiApp) {
5 // ...
6
7 app.customFields.register({
8 name: 'geo-picker',
9 type: 'json',
10 icon: PinMap,
11 intlLabel: {
12 id: 'custom.fields.geo-picker.label',
13 defaultMessage: 'Geo Position',
14 },
15 intlDescription: {
16 id: 'custom.fields.geo-picker.description',
17 defaultMessage: 'Enter geographic coordinates',
18 },
19 components: {
20 Input: () => ({ default: GeoPicker as React.ComponentType }) as any,
21 },
22 });
23
24// ...
25}
The complete file should look like this:
1import { getTranslation } from "./utils/getTranslation";
2import { PLUGIN_ID } from "./pluginId";
3import { Initializer } from "./components/Initializer";
4import { PluginIcon } from "./components/PluginIcon";
5import { PinMap } from "@strapi/icons";
6import { GeoPicker } from "./components/GeoPicker";
7
8export default {
9 register(app: any) {
10 app.addMenuLink({
11 to: `plugins/${PLUGIN_ID}`,
12 icon: PluginIcon,
13 intlLabel: {
14 id: `${PLUGIN_ID}.plugin.name`,
15 defaultMessage: PLUGIN_ID,
16 },
17 Component: async () => {
18 const { App } = await import("./pages/App");
19
20 return App;
21 },
22 });
23
24 app.customFields.register({
25 name: "geo-picker",
26 type: "json",
27 icon: PinMap,
28 intlLabel: {
29 id: "custom.fields.geo-picker.label",
30 defaultMessage: "Geo Position",
31 },
32 intlDescription: {
33 id: "custom.fields.geo-picker.description",
34 defaultMessage: "Enter geographic coordinates",
35 },
36 components: {
37 Input: () => ({ default: GeoPicker as React.ComponentType } as any),
38 },
39 });
40
41 app.registerPlugin({
42 id: PLUGIN_ID,
43 initializer: Initializer,
44 isReady: false,
45 name: PLUGIN_ID,
46 });
47 },
48
49 async registerTrads({ locales }: { locales: string[] }) {
50 return Promise.all(
51 locales.map(async (locale) => {
52 try {
53 const { default: data } = await import(
54 `./translations/${locale}.json`
55 );
56
57 return { data, locale };
58 } catch {
59 return { data: {}, locale };
60 }
61 })
62 );
63 },
64};
Register the custom field with the server in plugins/truck-tracker/server/src/register.ts
:
1// Register the custom field
2strapi.customFields.register({
3 name: "geo-picker",
4 type: "json",
5});
Update the truck schema to use the custom field in plugins/truck-tracker/server/src/content-types/truck.ts
:
1 position: {
2 type: 'customField',
3 customField: 'global::geo-picker',
4 required: true
5 },
The complete file should look like this:
1export default {
2 schema: {
3 kind: "collectionType",
4 collectionName: "trucks",
5 info: {
6 singularName: "truck",
7 pluralName: "trucks",
8 displayName: "Delivery Truck",
9 description: "",
10 },
11 // Specify where plugin-created content types are visible in the Strapi admin
12 pluginOptions: {
13 "content-manager": {
14 visible: true,
15 },
16 "content-type-builder": {
17 visible: false,
18 },
19 },
20 attributes: {
21 // how a truck identifies itself, like a license plate number
22 identifier: {
23 type: "string",
24 required: true,
25 },
26 // model of truck
27 model: {
28 type: "enumeration",
29 required: true,
30 enum: [
31 "Toyota Corolla",
32 "Toyota RAV4",
33 "Ford F-Series",
34 "Honda CR-V",
35 "Dacia Sandero",
36 ],
37 },
38 // gps coordinates in the form { latitude, longitude }
39 position: {
40 type: "customField",
41 customField: "global::geo-picker",
42 required: true,
43 },
44 // timestamp for when a truck was last updated
45 positionUpdatedAt: {
46 type: "datetime",
47 },
48 // password-like key for each truck to be able to update its position
49 key: {
50 type: "string",
51 required: true,
52 private: true,
53 },
54 },
55 },
56};
Once everything is done, back int the Strapi Admin, we should see the new custom field in the DeliveryTrucks
content type.
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
To add the map, go back to plugins/truck-tracker/admin/src/components/GeoPicker.tsx
and paste in the same map code from before.
1import { Box, Field, Flex, Typography } from "@strapi/design-system";
2import React, { useState } from "react";
3import { MapContainer, Marker, TileLayer, useMapEvents } from "react-leaflet";
4import "leaflet/dist/leaflet.css";
5import styled from "styled-components";
6
7// #region Types and Styles
8interface GeoPosition {
9 latitude: number;
10 longitude: number;
11}
12
13interface GeoPickerProps {
14 name: string;
15 onChange: (event: {
16 target: { name: string; value: object; type: string };
17 }) => void;
18 value?: GeoPosition;
19 intlLabel?: {
20 defaultMessage: string;
21 };
22 required?: boolean;
23}
24
25interface MapEventsProps {
26 onLocationSelected: (lat: number, lng: number) => void;
27}
28
29// Styles
30const MapWrapper = styled.div`
31 height: 400px;
32 width: 100%;
33 margin-bottom: 16px;
34
35 .leaflet-container {
36 z-index: 0;
37 height: 100%;
38 width: 100%;
39 border-radius: 4px;
40 }
41`;
42// #endregion
43
44// Map Events Component
45const MapEvents: React.FC<MapEventsProps> = ({ onLocationSelected }) => {
46 useMapEvents({
47 click: (e: any) => {
48 onLocationSelected(e.latlng.lat, e.latlng.lng);
49 },
50 });
51
52 return null;
53};
54
55// Default position (Paris)
56const DEFAULT_POSITION: GeoPosition = {
57 latitude: 48.8854611,
58 longitude: 2.3284453,
59};
60
61const GeoPicker: React.FC<GeoPickerProps> = ({
62 name,
63 onChange,
64 value,
65 intlLabel,
66 required,
67}) => {
68 const [position, setPosition] = useState<GeoPosition>(() => {
69 try {
70 return value ?? DEFAULT_POSITION;
71 } catch {
72 return DEFAULT_POSITION;
73 }
74 });
75
76 // onChange is how we tell Strapi what the current value of our custom field is
77 const handlePositionChange = (lat: number, lng: number) => {
78 const newPosition = {
79 latitude: lat,
80 longitude: lng,
81 };
82
83 setPosition(newPosition);
84
85 onChange({
86 target: {
87 name,
88 value: newPosition,
89 type: "json",
90 },
91 });
92 };
93
94 return (
95 <Field.Root name={name} required={required}>
96 <Field.Label>{intlLabel?.defaultMessage ?? "Location"}</Field.Label>
97 <Box padding={4}>
98 <MapWrapper>
99 <MapContainer
100 center={[position.latitude, position.longitude]}
101 zoom={20}
102 scrollWheelZoom
103 >
104 <TileLayer
105 attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
106 url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
107 />
108 <Marker position={[position.latitude, position.longitude]} />
109 <MapEvents onLocationSelected={handlePositionChange} />
110 </MapContainer>
111 </MapWrapper>
112
113 <Flex gap={4}>
114 <Typography>Latitude: {position.latitude}</Typography>
115 <Typography>Longitude: {position.longitude}</Typography>
116 </Flex>
117 </Box>
118 <Field.Error />
119 <Field.Hint />
120 </Field.Root>
121 );
122};
123
124export { GeoPicker };
If you go to view the map in the Admin, you may see that the map appears broken, with none of the images displaying.
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.
1 // replace 'strapi::security' with this object:
2 {
3 name: 'strapi::security',
4 config: {
5 contentSecurityPolicy: {
6 useDefaults: true,
7 directives: {
8 'connect-src': ["'self'", 'https:'],
9 'script-src': ["'self'", 'unsafe-inline', 'https://*.basemaps.cartocdn.com'],
10 'media-src': [
11 "'self'",
12 'blob:',
13 'data:',
14 'https://*.basemaps.cartocdn.com',
15 'https://tile.openstreetmap.org',
16 'https://*.tile.openstreetmap.org',
17 ],
18 'img-src': [
19 "'self'",
20 'blob:',
21 'data:',
22 'https://*.basemaps.cartocdn.com',
23 'market-assets.strapi.io',
24 'https://*.tile.openstreetmap.org',
25 'https://unpkg.com/leaflet@1.9.4/dist/images/',
26 ],
27 },
28 },
29 },
30 },
Try it again, and now it should display properly!
We'll create a dashboard widget that shows all trucks on a map. This widget will:
First, let's create a basic widget with just a map (no trucks) in plugins/truck-tracker/admin/src/components/MapWidget.tsx
:
1import React from "react";
2import { MapContainer, TileLayer } from "react-leaflet";
3import "leaflet/dist/leaflet.css";
4import styled from "styled-components";
5
6// # region Types and Styles
7// Styled components
8const MapWrapper = styled.div`
9 height: 100%;
10 width: 100%;
11
12 .leaflet-container {
13 height: 100%;
14 width: 100%;
15 border-radius: 4px;
16 }
17`;
18// #endregion
19
20// Default position (Paris)
21const DEFAULT_POSITION = [48.8854611, 2.3284453] as [number, number];
22
23const MapWidget: React.FC = () => {
24 return (
25 <MapWrapper>
26 <MapContainer center={DEFAULT_POSITION} zoom={20} scrollWheelZoom>
27 <TileLayer
28 attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
29 url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
30 />
31 </MapContainer>
32 </MapWrapper>
33 );
34};
35
36export { MapWidget };
Register the widget in plugins/truck-tracker/admin/src/index.ts
:
1// ...
2import { PinMap, Globe } from "@strapi/icons";
3import { MapWidget } from "./components/MapWidget";
4
5app.widgets.register({
6 icon: Globe,
7 title: {
8 id: `${PLUGIN_ID}.mywidget.title`,
9 defaultMessage: "Trucks Live Tracker",
10 },
11 component: () => Promise.resolve(MapWidget),
12 pluginId: PLUGIN_ID,
13 id: "mywidget",
14});
15// ...
The complete file should look like this:
1import { getTranslation } from "./utils/getTranslation";
2import { PLUGIN_ID } from "./pluginId";
3import { Initializer } from "./components/Initializer";
4import { PluginIcon } from "./components/PluginIcon";
5import { Globe, PinMap } from "@strapi/icons";
6import { GeoPicker } from "./components/GeoPicker";
7import { MapWidget } from "./components/MapWidget";
8
9export default {
10 register(app: any) {
11 app.addMenuLink({
12 to: `plugins/${PLUGIN_ID}`,
13 icon: PluginIcon,
14 intlLabel: {
15 id: `${PLUGIN_ID}.plugin.name`,
16 defaultMessage: PLUGIN_ID,
17 },
18 Component: async () => {
19 const { App } = await import("./pages/App");
20
21 return App;
22 },
23 });
24
25 app.customFields.register({
26 name: "geo-picker",
27 type: "json",
28 icon: PinMap,
29 intlLabel: {
30 id: "custom.fields.geo-picker.label",
31 defaultMessage: "Geo Position",
32 },
33 intlDescription: {
34 id: "custom.fields.geo-picker.description",
35 defaultMessage: "Enter geographic coordinates",
36 },
37 components: {
38 Input: () => ({ default: GeoPicker as React.ComponentType } as any),
39 },
40 });
41
42 app.widgets.register({
43 icon: Globe,
44 title: {
45 id: `${PLUGIN_ID}.mywidget.title`,
46 defaultMessage: "Trucks Live Tracker",
47 },
48 component: () => Promise.resolve(MapWidget),
49 pluginId: PLUGIN_ID,
50 id: "mywidget",
51 });
52
53 app.registerPlugin({
54 id: PLUGIN_ID,
55 initializer: Initializer,
56 isReady: false,
57 name: PLUGIN_ID,
58 });
59 },
60
61 async registerTrads({ locales }: { locales: string[] }) {
62 return Promise.all(
63 locales.map(async (locale) => {
64 try {
65 const { default: data } = await import(
66 `./translations/${locale}.json`
67 );
68 return { data, locale };
69 } catch {
70 return { data: {}, locale };
71 }
72 })
73 );
74 },
75};
If you go to the Strapi admin home page, you should now see the empty widget displayed.
Now, let's add some hard-coded truck data to 'MapWidget.tsx' and see how it will look with Trucks:
1import { Link } from "@strapi/design-system";
2import React, { useEffect, useState } from "react";
3import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet";
4import "leaflet/dist/leaflet.css";
5import styled from "styled-components";
6
7// #region Types & Styles
8interface Truck {
9 identifier: string;
10 documentId: string;
11 name: string;
12 model: string;
13 position: {
14 latitude: number;
15 longitude: number;
16 };
17}
18
19interface MapEventsProps {
20 onLocationSelected: (latitude: number, longitude: number) => void;
21}
22
23// Styled components
24const MapWrapper = styled.div`
25 height: 100%;
26 width: 100%;
27
28 .leaflet-container {
29 height: 100%;
30 width: 100%;
31 border-radius: 4px;
32 }
33`;
34
35// #endregion
36
37// Default position (Paris)
38const DEFAULT_TRUCKS: Truck[] = [
39 {
40 documentId: "ABC",
41 identifier: "123-ABC",
42 position: { latitude: 48.8854611, longitude: 2.3284453 },
43 name: "Bob",
44 model: "Corolla",
45 },
46];
47
48const MapWidget: React.FC<MapEventsProps> = () => {
49 const [trucks] = useState<Truck[]>(DEFAULT_TRUCKS);
50 const [zoom] = useState<number>(9);
51
52 return (
53 <MapWrapper>
54 <MapContainer
55 center={[48.8854611, 2.3284453]}
56 zoom={zoom}
57 scrollWheelZoom
58 >
59 <TileLayer
60 attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
61 url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
62 />
63 {trucks.map((truck) => (
64 <TruckMarker key={truck.identifier} truck={truck} />
65 ))}
66 </MapContainer>
67 </MapWrapper>
68 );
69};
70
71// Individual truck marker component
72const TruckMarker: React.FC<{ truck: Truck }> = ({ truck }) => {
73 const { backendURL } = window.strapi as any;
74 const href = `${backendURL}/admin/content-manager/collection-types/plugin::truck-tracker.truck/${truck.documentId}`;
75
76 return (
77 <Marker position={[truck.position.latitude, truck.position.longitude]}>
78 <Popup className="request-popup">
79 <h1 style={{ fontWeight: "bold", fontSize: "1.5rem" }}>{truck.name}</h1>
80 <p style={{ fontSize: "1rem" }}>{truck.model}</p>
81 <Link href={href} target="_blank">
82 Open in content manager
83 </Link>
84 </Popup>
85 </Marker>
86 );
87};
88
89export { MapWidget };
Check the homepage again to see how it looks. Now you should see Bob's truck on the map!
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
1import { Core } from "@strapi/strapi";
2
3const truck = ({ strapi }: { strapi: Core.Strapi }): Core.Controller => ({
4 async getTruckPositions(ctx) {
5 const trucks = await strapi
6 .documents("plugin::truck-tracker.truck")
7 // Only select the necessary fields in the query
8 .findMany({
9 fields: ["identifier", "model", "position", "positionUpdatedAt"],
10 });
11
12 return ctx.send(trucks);
13 },
14});
15
16export default truck;
Export the controller from the plugins/truck-tracker/server/src/controllers/index.ts
file:
1import controller from "./controller";
2import truck from "./truck";
3
4export default {
5 controller,
6 truck,
7};
Create file plugins/truck-tracker/server/src/routes/admin-api.ts
:
1export default [
2 {
3 method: "GET",
4 // this will appear at localhost:1337/truck-tracker/truck-positions
5 path: "/truck-positions",
6 handler: "truck.getTruckPositions",
7 config: {
8 // in the real world, you may want to add a custom policy
9 policies: ["admin::isAuthenticatedAdmin"],
10 },
11 },
12];
In plugin/truck-tracker/server/src/routes/index.ts
we need to add the admin routes:
1import contentAPIRoutes from "./content-api";
2import adminAPIRoutes from "./admin-api";
3
4const routes = {
5 "content-api": {
6 type: "content-api",
7 routes: contentAPIRoutes,
8 },
9 "admin-api": {
10 type: "admin",
11 routes: adminAPIRoutes,
12 },
13};
14
15export default routes;
Update the MapWidget component to fetch and display truck data:
1import { Link } from "@strapi/design-system";
2import React, { useEffect, useState } from "react";
3import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet";
4import "leaflet/dist/leaflet.css";
5import styled from "styled-components";
6import { useFetchClient } from "@strapi/strapi/admin";
7
8// #region Types & Styles
9interface Truck {
10 identifier: string;
11 documentId: string;
12 name: string;
13 model: string;
14 position: {
15 latitude: number;
16 longitude: number;
17 };
18}
19
20interface MapEventsProps {
21 onLocationSelected: (latitude: number, longitude: number) => void;
22}
23
24// Styled components
25const MapWrapper = styled.div`
26 height: 100%;
27 width: 100%;
28
29 .leaflet-container {
30 height: 100%;
31 width: 100%;
32 border-radius: 4px;
33 }
34`;
35
36// #endregion
37
38// Default position (Paris)
39const DEFAULT_TRUCKS: Truck[] = [
40 {
41 documentId: "ABC",
42 identifier: "123-ABC",
43 position: { latitude: 48.8854611, longitude: 2.3284453 },
44 name: "Test Truck Bob",
45 model: "Corolla",
46 },
47];
48
49const MapWidget: React.FC<MapEventsProps> = () => {
50 const [trucks, setTrucks] = useState<Truck[]>(DEFAULT_TRUCKS);
51 const [zoom] = useState<number>(9);
52
53 // this ensure the front-end request includes Strapi auth headers
54 const { get } = useFetchClient();
55
56 useEffect(() => {
57 const fetchTruckPositions = async () => {
58 try {
59 const { data } = await get("/truck-tracker/truck-positions");
60
61 setTrucks(data);
62 } catch (error) {
63 console.error("Error fetching truck positions:", error);
64 }
65 };
66
67 fetchTruckPositions().then();
68 }, []);
69
70 return (
71 <MapWrapper>
72 <MapContainer
73 center={[48.8854611, 2.3284453]}
74 zoom={zoom}
75 scrollWheelZoom
76 >
77 <TileLayer
78 attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
79 url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
80 />
81 {trucks.map((truck) => (
82 <TruckMarker key={truck.identifier} truck={truck} />
83 ))}
84 </MapContainer>
85 </MapWrapper>
86 );
87};
88
89// Individual truck marker component
90const TruckMarker: React.FC<{ truck: Truck }> = ({ truck }) => {
91 const { backendURL } = window.strapi as any;
92 const href = `${backendURL}/admin/content-manager/collection-types/plugin::truck-tracker.truck/${truck.documentId}`;
93
94 return (
95 <Marker position={[truck.position.latitude, truck.position.longitude]}>
96 <Popup className="request-popup">
97 <h1 style={{ fontWeight: "bold", fontSize: "1.5rem" }}>{truck.name}</h1>
98 <p style={{ fontSize: "1rem" }}>{truck.model}</p>
99 <Link href={href} target="_blank">
100 Open in content manager
101 </Link>
102 </Popup>
103 </Marker>
104 );
105};
106
107export { MapWidget };
Take a look at the admin and check that it's working!
In the content manager go ahead and add couple of trucks.
You should be able to see the trucks in our Map Widget at the homepage.
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:
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
:
1// add : Core.Controller type to controller types to get a fully typed method
2const controller = ({ strapi }: { strapi: Core.Strapi }): Core.Controller => ({
3
4 // ...
5
6 async updateTruckPosition(ctx) {
7 const { identifier, latitude, longitude } = ctx.request.body;
8
9 // Get the truck
10 const truck = await strapi.documents('plugin::truck-tracker.truck').findFirst({
11 filters: { identifier },
12 });
13
14 if (!truck) {
15 return ctx.notFound('Truck not found');
16 }
17
18 const updatedTruckPosition = await strapi.documents('plugin::truck-tracker.truck').update({
19 documentId: truck.documentId,
20 data: {
21 position: {
22 latitude,
23 longitude,
24 },
25 } as any,
26 });
27
28 return {
29 data: {
30 identifier: updatedTruckPosition.identifier,
31 position: updatedTruckPosition.position,
32 positionUpdatedAt: updatedTruckPosition.positionUpdatedAt,
33 },
34 };
35 },
36
37// ...
The complete file should look like this:
1import type { Core } from "@strapi/strapi";
2
3const controller = ({ strapi }: { strapi: Core.Strapi }): Core.Controller => ({
4 index(ctx) {
5 ctx.body = strapi
6 .plugin("truck-tracker")
7 // the name of the service file & the method.
8 .service("service")
9 .getWelcomeMessage();
10 },
11
12 async updateTruckPosition(ctx) {
13 const { identifier, latitude, longitude } = ctx.request.body;
14
15 // Get the truck
16 const truck = await strapi
17 .documents("plugin::truck-tracker.truck")
18 .findFirst({
19 filters: { identifier },
20 });
21
22 if (!truck) {
23 return ctx.notFound("Truck not found");
24 }
25
26 const updatedTruckPosition = await strapi
27 .documents("plugin::truck-tracker.truck")
28 .update({
29 documentId: truck.documentId,
30 data: {
31 position: {
32 latitude,
33 longitude,
34 },
35 } as any,
36 });
37
38 return {
39 data: {
40 identifier: updatedTruckPosition.identifier,
41 position: updatedTruckPosition.position,
42 positionUpdatedAt: updatedTruckPosition.positionUpdatedAt,
43 },
44 };
45 },
46});
47
48export default controller;
In plugins/truck-tracker/server/src/routes/content-api.ts
add the following route:
1 {
2 method: "POST",
3 path: "/update-position",
4 // name of the controller file & the method.
5 handler: "controller.updateTruckPosition",
6 config: {
7 policies: [],
8 auth: false,
9 },
10 auth: false,
11 },
The complete file should look like this:
1export default [
2 {
3 method: "GET",
4 path: "/",
5 // name of the controller file & the method.
6 handler: "controller.index",
7 config: {
8 policies: [],
9 },
10 },
11
12 {
13 method: "POST",
14 path: "/update-position",
15 // name of the controller file & the method.
16 handler: "controller.updateTruckPosition",
17 config: {
18 policies: [],
19 auth: false,
20 },
21 auth: false,
22 },
23];
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
:
1interface UpdatePositionArgs {
2 identifier: string;
3 latitude: number;
4 longitude: number;
5 key: string;
6}
7
8interface ApiResponse {
9 message?: string;
10 error?: {
11 status: number;
12 name: string;
13 message: string;
14 details?: any;
15 };
16 [key: string]: any;
17}
18
19export async function updateTruckPosition({
20 identifier,
21 latitude,
22 longitude,
23 key,
24}: UpdatePositionArgs) {
25 try {
26 const url = "http://localhost:1337/api/truck-tracker/update-position";
27 const requestData = {
28 identifier,
29 latitude,
30 longitude,
31 key,
32 };
33
34 console.log("Sending request to:", url);
35 console.log("Request data:", JSON.stringify(requestData, null, 2));
36
37 const response = await fetch(url, {
38 method: "POST",
39 headers: {
40 "Content-Type": "application/json",
41 },
42 body: JSON.stringify(requestData),
43 });
44
45 const data = (await response.json()) as ApiResponse;
46
47 if (!response.ok) {
48 console.error("Full error response:", JSON.stringify(data, null, 2));
49 throw new Error(
50 data.error?.message || data.message || "Failed to update position"
51 );
52 }
53
54 console.log("Position updated successfully:", data);
55 } catch (error) {
56 console.error(
57 "Error updating position:",
58 error instanceof Error ? error.message : error
59 );
60 }
61}
62
63// Run if called directly
64if (require.main === module) {
65 const args = process.argv.slice(2);
66
67 if (args.length !== 4) {
68 console.error(
69 "Usage: ts-node update-truck-position.ts <identifier> <latitude> <longitude> <key>"
70 );
71 process.exit(1);
72 }
73
74 const [identifier, latitude, longitude, key] = args;
75
76 updateTruckPosition({
77 identifier,
78 latitude: parseFloat(latitude),
79 longitude: parseFloat(longitude),
80 key,
81 }).catch((error) => {
82 console.error("Script failed:", error);
83 process.exit(1);
84 });
85}
Create a truck in the admin with identifier 'ABC' and key '123', and test it out:
npx ts-node ./scripts/update-truck-position.ts ABC 52.4 13.4 123
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
}
}
We were able to update the truck position. Nice!
We'll add a policy to secure the position update endpoint. This policy:
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:
1import { Core } from "@strapi/strapi";
2
3export default {
4 "verify-truck-key": async (
5 policyContext: Core.PolicyContext,
6 _config: unknown,
7 { strapi }: { strapi: Core.Strapi }
8 ) => {
9 const { identifier, key } = policyContext.request.body;
10
11 const truck = await strapi
12 .documents("plugin::truck-tracker.truck")
13 .findFirst({
14 filters: { identifier },
15 });
16
17 return truck?.key === key;
18 },
19};
Add it to the route found in plugins/truck-tracker/server/src/routes/content-api.ts
add the following for policy:
1// ...
2 config: {
3 policies: ["verify-truck-key"],
4 auth: false,
5 },
The complete file should look like this:
1export default [
2 {
3 method: "GET",
4 path: "/",
5 // name of the controller file & the method.
6 handler: "controller.index",
7 config: {
8 policies: [],
9 },
10 },
11
12 {
13 method: "POST",
14 path: "/update-position",
15 // name of the controller file & the method.
16 handler: "controller.updateTruckPosition",
17 config: {
18 policies: ["verify-truck-key"],
19 auth: false,
20 },
21 auth: false,
22 },
23];
Test with a wrong key (should fail):
npx ts-node ./scripts/update-truck-position.ts ABC 52.4 13.4 wrong
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
And with the correct key (should succeed):
npx ts-node ./scripts/update-truck-position.ts ABC 52.4 13.4 123
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
}
}
We'll add middleware to automatically update the positionUpdatedAt
timestamp. This middleware:
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
:
1import type { Core } from "@strapi/strapi";
2
3interface Position {
4 latitude: number;
5 longitude: number;
6}
7
8interface TruckData {
9 position?: Position;
10 positionUpdatedAt?: string;
11}
12
13const register = ({ strapi }: { strapi: Core.Strapi }) => {
14 // Register the custom field
15 strapi.customFields.register({
16 name: "geo-picker",
17 type: "json",
18 });
19
20 strapi.documents.use(async (context, next) => {
21 if (
22 context.uid === "plugin::truck-tracker.truck" &&
23 context.action === "update"
24 ) {
25 const { data } = context.params as { data: TruckData };
26
27 const originalData = (await strapi
28 .documents("plugin::truck-tracker.truck")
29 .findOne({ documentId: context.params.documentId })) as TruckData;
30
31 const { position: newPos } = data;
32 const { position: oldPos } = originalData;
33
34 // Only update if coordinates have actually changed
35 if (
36 newPos?.latitude !== oldPos?.latitude ||
37 newPos?.longitude !== oldPos?.longitude
38 ) {
39 data.positionUpdatedAt = new Date().toISOString();
40 }
41 }
42
43 return next();
44 });
45};
46
47export default register;
Make sure to rebuild the plugin:
yarn build
yarn watch
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
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'
}
}
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
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'
}
}
Now that you've built the complete truck tracker plugin, let's test it:
Create a new truck in the admin panel
View the truck on the dashboard
Update the truck's position
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.
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!
Ben is an American living in Italy who has been developing web apps since long before it was cool. In his spare time, he likes cooking and eating, which go together perfectly.