✨ Strapi MCP is now Generally Available - let your agents manage your Strapi content ✨

Ecosystem23 min read

How to Build a Field Service and Technician Dispatch App with Next.js 16 and Strapi 5

July 29, 2026
How to Build a Field Service and Technician Dispatch App with Next.js 16 and Strapi 5

Dispatch operations break down at the seams between people. A client calls in a broken HVAC unit, a dispatcher scribbles the address, a technician gets a vague text, and nobody knows the job's real status until someone picks up the phone. The coordination problem isn't hard because the technology is hard. It's hard because three different roles need three different views of the same data, and that data has to stay consistent while it moves through a strict lifecycle.

This tutorial walks you through building a field service dispatch app with Strapi and Next.js that handles all three roles cleanly. Strapi 5 provides robust role-based access control, supports review workflow transitions for content stages (in paid plans), can trigger generic webhooks on entry updates (including status changes), but requires custom logic for sorting technicians by proximity and for validating arbitrary job status transitions at the data layer. Next.js 16 renders three role-scoped dashboards using App Router route groups and Server Actions.

By the end, you'll have a working system where a client submits a request, a dispatcher assigns the nearest available technician, that technician updates status from the field, and the client tracks progress in real time.

In brief:

  • Configure three Users & Permissions roles (Dispatcher, Technician, Client) with granular CRUD permissions per Content-Type.
  • Enforce a valid job status pipeline with a Document Service middleware that fires exactly once per call.
  • Sort technicians by distance to a job using a Haversine calculation in a custom Strapi service, no external mapping API required.
  • Build three role-scoped Next.js 16 dashboards with route groups, Server Components for reads, and Server Actions for mutations.

What We're Building

The app coordinates three roles around a single source of truth. A Client submits a service request with an address and description. A Dispatcher sees every job, converts requests into jobs, and assigns the closest available technician from a proximity-sorted list. A Technician sees only their assigned jobs on a mobile-friendly view and advances each one through the status pipeline from the field.

Strapi 5 does the heavy lifting on the backend. The Users & Permissions plugin scopes API access per role. A Document Service middleware enforces the status pipeline so a job can't jump from submitted straight to completed. A custom service ranks technicians by distance to the job's coordinates. Webhooks notify external systems when a technician is en route or finishes a job.

Next.js 16 renders the frontend. Route groups isolate each role's layout and auth check without polluting URL paths. Server Components fetch data on the server with JWT auth, and Server Actions handle mutations with cache revalidation. Cookie-based JWT auth ties the two together, so each role's session stays on the server and never leaks a token to the browser.

What you'll learn:

  • Modeling Job, Technician, and Service Request Content-Types with relations and coordinate fields
  • Configuring three RBAC roles through the Users & Permissions plugin
  • Writing a Document Service middleware that validates status transitions
  • Building a proximity assignment service with the Haversine formula
  • Wiring webhooks to status changes
  • Rendering role-scoped dashboards with Next.js 16 route groups and Server Actions

Prerequisites

Before starting, make sure your environment matches these pinned versions:

You also need working knowledge of TypeScript, React Server Components, and REST APIs, plus a code editor like VS Code. A running PostgreSQL 17 instance is needed for the database steps.

Dispatch teams usually run lean. One coordinator might handle dozens of open jobs across a metro area, so the data model has to make the current state of every job obvious at a glance. Keep that constraint in mind as you build the Content-Types: every field below earns its place by answering a question a dispatcher asks during a busy shift.

Setting Up the Strapi Backend

Step 1: Install Strapi 5

Create the project with the standard CLI command. The --quickstart flag is deprecated in Strapi 5; use the interactive prompts or --non-interactive instead.

npx create-strapi@latest dispatch-backend

For a non-interactive PostgreSQL setup, pass the database flags directly:

npx create-strapi@latest dispatch-backend \
  --non-interactive \
  --dbclient postgres \
  --dbhost 127.0.0.1 \
  --dbport 5432 \
  --dbname dispatch \
  --dbusername dispatch_user \
  --dbpassword your_password

The database user connecting Strapi to PostgreSQL needs SCHEMA permissions. A newly created user won't have these by default, which produces a 500 error on the admin console. Grant them before starting.

Your config/database.ts reads connection details from environment variables:

// config/database.ts
export default ({ env }) => ({
  connection: {
    client: env('DATABASE_CLIENT'),
    connection: {
      host: env('DATABASE_HOST'),
      port: env.int('DATABASE_PORT'),
      database: env('DATABASE_NAME'),
      user: env('DATABASE_USERNAME'),
      password: env('DATABASE_PASSWORD'),
      ssl: env.bool('DATABASE_SSL', false),
    },
    pool: {
      min: 0,
      max: 10,
      acquireTimeoutMillis: 60000,
      idleTimeoutMillis: 30000,
    },
  },
  settings: {
    useTypescriptMigrations: true,
  },
});

Start the development server:

npm run develop

Create your first admin user at http://localhost:1337/admin.

Step 2: Define the Job, Technician, and Service Request Content-Types

You can build these through the Content-Type Builder in the Admin Panel, or write the schema.json files directly. The schema approach is faster to review and version-control. Each Content-Type lives at src/api/[api-name]/content-types/[name]/schema.json.

All three operational Content-Types set draftAndPublish: false. Operational records should never sit in a draft state that could accidentally hide an active job from the API.

The Job Content-Type holds the service address, coordinates (latitude and longitude), priority, the status enum, completion notes, and relations to the technician, service request, and client:

{
  "kind": "collectionType",
  "collectionName": "jobs",
  "info": {
    "singularName": "job",
    "pluralName": "jobs",
    "displayName": "Job"
  },
  "options": {
    "draftAndPublish": false
  },
  "pluginOptions": {},
  "attributes": {
    "title": { "type": "string" },
    "description": { "type": "text" },
    "priority": {
      "type": "enumeration",
      "enum": ["low", "medium", "high"]
    },
    "serviceAddress": { "type": "string" },
    "latitude": { "type": "decimal" },
    "longitude": { "type": "decimal" },
    "completionNotes": { "type": "text" },
    "status": {
      "type": "enumeration",
      "enum": ["submitted", "assigned", "en_route", "in_progress", "completed", "escalated"]
    },
    "scheduledAt": { "type": "datetime" },
    "isUrgent": { "type": "boolean" },
    "technician": {
      "type": "relation",
      "relation": "manyToOne",
      "target": "api::technician.technician",
      "inversedBy": "jobs"
    },
    "serviceRequest": {
      "type": "relation",
      "relation": "oneToOne",
      "target": "api::service-request.service-request",
      "inversedBy": "job"
    },
    "client": {
      "type": "relation",
      "relation": "manyToOne",
      "target": "plugin::users-permissions.user"
    }
  }
}

Use decimal for latitude and longitude, datetime for timestamps, and enumeration for the status pipeline. The field types cover everything you need.

The Technician Content-Type stores availability, current coordinates, and the inverse side of the job relation:

{
  "kind": "collectionType",
  "collectionName": "technicians",
  "info": {
    "singularName": "technician",
    "pluralName": "technicians",
    "displayName": "Technician"
  },
  "options": {
    "draftAndPublish": false
  },
  "pluginOptions": {},
  "attributes": {
    "name": { "type": "string" },
    "email": { "type": "email" },
    "latitude": { "type": "decimal" },
    "longitude": { "type": "decimal" },
    "availability": {
      "type": "enumeration",
      "enum": ["available", "busy", "offline"]
    },
    "isActive": { "type": "boolean" },
    "jobs": {
      "type": "relation",
      "relation": "oneToMany",
      "target": "api::job.job",
      "mappedBy": "technician"
    }
  }
}

The Service Request Content-Type captures what the client submits before it becomes a job:

{
  "kind": "collectionType",
  "collectionName": "service_requests",
  "info": {
    "singularName": "service-request",
    "pluralName": "service-requests",
    "displayName": "Service Request"
  },
  "options": {
    "draftAndPublish": false
  },
  "pluginOptions": {},
  "attributes": {
    "description": { "type": "text" },
    "status": {
      "type": "enumeration",
      "enum": ["pending", "assigned", "completed", "cancelled"]
    },
    "latitude": { "type": "decimal" },
    "longitude": { "type": "decimal" },
    "address": { "type": "string" },
    "preferredDate": { "type": "datetime" },
    "requestedAt": { "type": "datetime" },
    "isEmergency": { "type": "boolean" },
    "job": {
      "type": "relation",
      "relation": "oneToOne",
      "target": "api::job.job",
      "mappedBy": "serviceRequest"
    }
  }
}

The owning side of a relation uses inversedBy; the inverse side uses mappedBy. If you are new to how relations work, that distinction is the part worth slowing down on. Target UIDs follow the api::[api-name].[content-type-name] format. After saving these files, restart Strapi so it picks up the new schemas.

Step 3: Configure Three RBAC Roles

Strapi 5 has two separate role systems for role-based access control. Admin Panel roles govern the admin UI. The Users & Permissions plugin roles govern API consumers, which is what your dispatch app needs.

In the Admin Panel, go to Settings → Users & Permissions plugin → Roles → Add new role. Create three roles:

Dispatcher gets full access. Tick find, findOne, create, update, and delete on Job, Technician, and Service Request.

Technician gets find, findOne, and update on Job (so they can read assigned jobs and advance status), plus find and update on their own Technician record.

Client gets create, find, and findOne on Service Request, and find plus findOne on Job (to track status).

For each role, name it, tick the CRUD checkboxes per Content-Type, then save. The Users & Permissions plugin scopes access at the action level per Content-Type, not per individual record, so the application code below also scopes data per role to keep clients from seeing each other's jobs.

Authentication uses JWT. A user registers through the local provider:

POST /api/auth/local/register
Content-Type: application/json

{
  "username": "jane_dispatcher",
  "email": "jane@example.com",
  "password": "Password123!"
}

The response uses Strapi 5's flat format with both id and documentId:

{
  "jwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "user": {
    "id": 2,
    "documentId": "xjpaytstw1gm7wdfoc6c2k13",
    "username": "jane_dispatcher",
    "email": "jane@example.com",
    "provider": "local",
    "confirmed": true,
    "blocked": false
  }
}

By default only username, email, and password are accepted at registration. To allow extra fields, configure the plugin:

// config/plugins.ts
export default {
  'users-permissions': {
    config: {
      register: {
        allowedFields: ['firstName', 'lastName'],
      },
    },
  },
};

Authenticated requests carry the token in the header:

Authorization: Bearer <your-jwt-token>

Step 4: Build Job Status Transition Middleware

A dispatch job moves through a strict pipeline. A technician shouldn't mark a job completed while it's still submitted. Enforce this at the data layer with a Document Service middleware, not a lifecycle hook.

Lifecycle hooks are unreliable in Strapi 5. They fire at the database layer, and a single Document Service call can trigger them multiple times. For instance, create() on a published entry fires the Create lifecycle twice. The lifecycle hooks breaking change documents this in detail. Document Service middlewares fire once per direct method call, but may fire multiple times if a high-level method invokes multiple internal calls, which makes them more predictable for status validation than lifecycle hooks.

Because core service methods wrap the Document Service, a middleware registered with strapi.documents.use() intercepts operations whether they come from the REST API, a custom controller, or a direct service call. The core service methods change explains this behavior.

Register the middleware in src/index.ts inside the register() lifecycle:

// src/index.ts
const JOB_UID = 'api::job.job';

const VALID_TRANSITIONS: Record<string, string[]> = {
  submitted: ['assigned'],
  assigned: ['en_route', 'escalated'],
  en_route: ['in_progress', 'escalated'],
  in_progress: ['completed', 'escalated'],
  completed: [],
  escalated: ['assigned'],
};

export default {
  register({ strapi }) {
    strapi.documents.use(async (context, next) => {
      if (context.uid !== JOB_UID) return next();
      if (!['update'].includes(context.action)) return next();

      const incomingStatus = context.params?.data?.status;
      if (!incomingStatus) return next();

      if (context.action === 'update') {
        const documentId = context.params.documentId;
        const current = await strapi.documents(JOB_UID).findOne({ documentId });

        const currentStatus = current?.status;
        const allowed = VALID_TRANSITIONS[currentStatus] ?? [];

        if (!allowed.includes(incomingStatus)) {
          throw new Error(
            `Invalid status transition: ${currentStatus} → ${incomingStatus}`
          );
        }
      }

      return next();
    });
  },

  bootstrap() {},
};

Always return the result of next(). Failing to do so breaks the Strapi application. The middleware reads context.uid to scope itself to jobs, checks the action, then compares the incoming status against the allowed transitions for the current status. An invalid jump throws before the database ever sees it.

Step 5: Build the Proximity Assignment Service

When a dispatcher needs to assign a job, they want the closest available technician first. You can calculate this with the Haversine formula, which gives great-circle distance between two coordinate pairs. No external mapping API required. For this tutorial, that calculation is enough to rank technicians by distance.

The JavaScript Math API expects trigonometric angles in radians, so the helper converts degrees first. Put the math in a shared library:

// src/lib/haversine.ts
function deg2Rad(deg: number): number {
  return deg * (Math.PI / 180);
}

export function getDistanceKm(
  lat1: number,
  lon1: number,
  lat2: number,
  lon2: number
): number {
  const R = 6371;
  const dLat = deg2Rad(lat2 - lat1);
  const dLon = deg2Rad(lon2 - lon1);

  const a =
    Math.sin(dLat / 2) * Math.sin(dLat / 2) +
    Math.cos(deg2Rad(lat1)) *
      Math.cos(deg2Rad(lat2)) *
      Math.sin(dLon / 2) *
      Math.sin(dLon / 2);

  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
  return R * c;
}

interface Technician {
  documentId: string;
  name: string;
  latitude: number;
  longitude: number;
  availability: 'available' | 'busy' | 'offline';
}

export function sortTechniciansByProximity(
  technicians: Technician[],
  originLat: number,
  originLng: number
): Array<Technician & { distanceKm: number }> {
  return [...technicians]
    .map((tech) => ({
      ...tech,
      distanceKm: getDistanceKm(
        originLat,
        originLng,
        tech.latitude,
        tech.longitude
      ),
    }))
    .sort((a, b) => a.distanceKm - b.distanceKm);
}

The helper uses R = 6371 to return distances in kilometers. Wire it into a custom service that queries available technicians through the Document Service:

// src/api/technician/services/technician.ts
const { createCoreService } = require('@strapi/strapi').factories;
const { sortTechniciansByProximity } = require('../../../lib/haversine');

module.exports = createCoreService(
  'api::technician.technician',
  ({ strapi }) => ({
    async findNearby({ lat, lng }: { lat: number; lng: number }) {
      const technicians = await strapi
        .documents('api::technician.technician')
        .findMany({
          filters: { availability: 'available', isActive: true },
        });

      return sortTechniciansByProximity(technicians, lat, lng);
    },
  })
);

Because the Entity Service API is deprecated in Strapi 5, findMany() from the Document Service replaces older methods. The controller calls the service and sanitizes the output, since the Document Service returns unsanitized data:

// src/api/technician/controllers/technician.ts
const { createCoreController } = require('@strapi/strapi').factories;

module.exports = createCoreController(
  'api::technician.technician',
  ({ strapi }) => ({
    async findNearby(ctx) {
      const { lat, lng } = ctx.query;

      const sorted = await strapi
        .service('api::technician.technician')
        .findNearby({ lat: Number(lat), lng: Number(lng) });

      const sanitized = await Promise.all(
        sorted.map((tech) =>
          this.sanitizeOutput(tech, ctx)
        )
      );

      return { data: sanitized };
    },
  })
);

Expose the endpoint with a custom route. Setting type: 'content-api' is necessary for a route to appear in the Users & Permissions permissions UI, but you must also ensure the controller action is bound to the content API and explicitly grant the permission to the relevant role in the admin panel. Prefix the filename with a number so it loads before the core routes, which load in alphabetical order:

// src/api/technician/routes/01-custom-technician.ts
export default {
  type: 'content-api',
  routes: [
    {
      method: 'GET',
      path: '/technicians/nearby',
      handler: 'api::technician.technician.findNearby',
      config: {
        policies: [],
      },
    },
  ],
};

After restarting, grant the Dispatcher role permission on the findNearby action in Settings → Users & Permissions → Roles.

To notify clients when status changes, configure a webhook in Settings → Webhooks → Add new webhook. Webhooks are a clean way to push real-time notifications to external systems without polling. Subscribe to the entry.update event on the Job model. Strapi fires a payload like this when a technician advances a job:

{
  "event": "entry.update",
  "createdAt": "2026-06-15T10:30:00.000Z",
  "model": "job",
  "entry": {
    "id": 1,
    "documentId": "rxngxzclq0zdaqtvz67hj38d",
    "title": "Fix HVAC Unit",
    "status": "in_progress",
    "updatedAt": "2026-06-15T10:30:00.000Z"
  }
}

Every delivery carries an X-Strapi-Event header with the event name, and private fields are excluded from the payload. One thing to know: webhooks do not fire for the User Content-Type, which prevents leaking user data to external systems. If you need User events, use a lifecycle hook instead.

Building the Next.js 16 Frontend

The frontend leans on Next.js 16 features like route groups, Server Components, and Server Actions to keep each role's data and JWT on the server. The three steps below build the dispatcher, technician, and client views in turn.

Step 1: Set Up the Next.js 16 Project

Create the frontend and install Tailwind CSS v4 and date-fns:

npx create-next-app@latest dispatch-frontend --typescript --eslint --app
cd dispatch-frontend
npm install tailwindcss @tailwindcss/postcss postcss date-fns

Configure PostCSS, which is the recommended setup for Tailwind v4:

// postcss.config.mjs
const config = {
  plugins: {
    "@tailwindcss/postcss": {},
  },
};
export default config;

Tailwind v4 replaces the three @tailwind directives with a single import. Content detection is automatic, so there's no content array to maintain:

/* app/globals.css */
@import "tailwindcss";

Add environment variables for the Strapi connection:

# .env.local
STRAPI_URL=http://localhost:1337

Build a small helper for cookie-based JWT authentication that both Server Components and Server Actions reuse:

// app/lib/auth.ts
import 'server-only';
import { cookies } from 'next/headers';

export async function getJwt(): Promise<string | undefined> {
  const cookieStore = await cookies();
  return cookieStore.get('strapi-jwt')?.value;
}

The login Server Action exchanges credentials for a JWT and stores it in an httpOnly cookie:

// app/actions/auth.ts
'use server';
import { cookies } from 'next/headers';

const STRAPI_URL = process.env.STRAPI_URL;

export async function loginUser(formData: FormData) {
  const res = await fetch(`${STRAPI_URL}/api/auth/local`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      identifier: formData.get('email'),
      password: formData.get('password'),
    }),
  });

  if (!res.ok) throw new Error('Login failed');
  const { jwt, user } = await res.json();

  const cookieStore = await cookies();
  cookieStore.set('strapi-jwt', jwt, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    path: '/',
  });

  return user;
}

Since Server Functions are reachable via direct POST requests, not just through your UI, always verify auth inside each one. The cookie check in the mutation actions below handles that.

Organize the three dashboards with route groups. The folder name in parentheses doesn't appear in the URL, so each role gets its own layout and auth check without affecting paths:

app/
  (dispatcher)/
    layout.tsx
    jobs/
      page.tsx
  (technician)/
    layout.tsx
    my-jobs/
      page.tsx
  (client)/
    layout.tsx
    requests/
      page.tsx

Step 2: Build the Dispatcher Dashboard

The dispatcher view lists every job with its technician and service request populated. Server Components run on the server, so the JWT and the Strapi fetch never reach the client. Populate is explicit here. Never use populate=* in production, since it can expose relations a lower-privilege role shouldn't see.

// app/(dispatcher)/jobs/page.tsx
import 'server-only';
import { getJwt } from '@/app/lib/auth';
import { AssignTechnician } from './assign-technician';

interface Job {
  documentId: string;
  title: string;
  status: string;
  priority: string;
  latitude: number;
  longitude: number;
  technician?: { documentId: string; name: string };
}

export default async function JobsPage() {
  const jwt = await getJwt();

  const res = await fetch(
    `${process.env.STRAPI_URL}/api/jobs?populate[0]=technician&populate[1]=serviceRequest`,
    {
      headers: { Authorization: `Bearer ${jwt}` },
      next: { tags: ['jobs'] },
    }
  );

  if (!res.ok) throw new Error('Failed to fetch jobs');
  const { data }: { data: Job[] } = await res.json();

  return (
    <div className="p-4">
      <h1 className="text-2xl font-semibold mb-4">All Jobs</h1>
      <table className="w-full text-left border-collapse">
        <thead>
          <tr className="border-b">
            <th className="p-2">Title</th>
            <th className="p-2">Status</th>
            <th className="p-2">Priority</th>
            <th className="p-2">Technician</th>
            <th className="p-2">Assign</th>
          </tr>
        </thead>
        <tbody>
          {data.map((job) => (
            <tr key={job.documentId} className="border-b">
              <td className="p-2">{job.title}</td>
              <td className="p-2">{job.status}</td>
              <td className="p-2">{job.priority}</td>
              <td className="p-2">{job.technician?.name ?? 'Unassigned'}</td>
              <td className="p-2">
                <AssignTechnician
                  jobId={job.documentId}
                  lat={job.latitude}
                  lng={job.longitude}
                />
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

Notice the flat response format. You read job.title and job.technician.name directly, not job.attributes.title. This is a breaking change from v4.

The assignment action fetches proximity-sorted technicians and connects the chosen one to the job. One-to-many relations use the connect syntax. For the many-to-one technician relation on the job, the shorter form passing a documentId directly works just as well:

// app/actions/jobs.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
import { getJwt } from '@/app/lib/auth';

const STRAPI_URL = process.env.STRAPI_URL;

export async function getNearbyTechnicians(lat: number, lng: number) {
  const jwt = await getJwt();

  const res = await fetch(
    `${STRAPI_URL}/api/technicians/nearby?lat=${lat}&lng=${lng}`,
    { headers: { Authorization: `Bearer ${jwt}` } }
  );

  if (!res.ok) throw new Error('Failed to fetch technicians');
  const { data } = await res.json();
  return data;
}

export async function assignTechnician(jobId: string, technicianId: string) {
  const jwt = await getJwt();
  if (!jwt) throw new Error('Unauthorized');

  const res = await fetch(`${STRAPI_URL}/api/jobs/${jobId}`, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${jwt}`,
    },
    body: JSON.stringify({
      data: {
        technician: { set: [technicianId] },
        status: 'assigned',
      },
    }),
  });

  if (!res.ok) throw new Error('Failed to assign technician');

  revalidatePath('/jobs');
  revalidateTag('jobs');
}

The assignment sets status to assigned, which the transition middleware permits from submitted. The Client Component renders the proximity-sorted dropdown:

// app/(dispatcher)/jobs/assign-technician.tsx
'use client';
import { useState } from 'react';
import { getNearbyTechnicians, assignTechnician } from '@/app/actions/jobs';

interface NearbyTech {
  documentId: string;
  name: string;
  distanceKm: number;
}

export function AssignTechnician({
  jobId,
  lat,
  lng,
}: {
  jobId: string;
  lat: number;
  lng: number;
}) {
  const [techs, setTechs] = useState<NearbyTech[]>([]);

  async function loadTechs() {
    const data = await getNearbyTechnicians(lat, lng);
    setTechs(data);
  }

  return (
    <div className="flex gap-2">
      <select
        onFocus={loadTechs}
        onChange={(e) => assignTechnician(jobId, e.target.value)}
        className="border rounded px-2 py-1 text-sm"
        defaultValue=""
      >
        <option value="" disabled>
          Select technician
        </option>
        {techs.map((t) => (
          <option key={t.documentId} value={t.documentId}>
            {t.name} ({t.distanceKm.toFixed(1)} km)
          </option>
        ))}
      </select>
    </div>
  );
}

Step 3: Build the Technician Field View

Technicians work from phones, so this view uses a responsive card grid. Tailwind is mobile-first: unprefixed utilities apply everywhere, and sm: or lg: variants kick in at that breakpoint and above. The grid goes from one column on phones to three on desktop.

// app/(technician)/my-jobs/page.tsx
import 'server-only';
import { getJwt } from '@/app/lib/auth';
import { formatDistanceToNow } from 'date-fns';
import { StatusActions } from './status-actions';

interface Job {
  documentId: string;
  title: string;
  description: string;
  status: string;
  serviceAddress: string;
  updatedAt: string;
}

export default async function MyJobsPage() {
  const jwt = await getJwt();

  const res = await fetch(
    `${process.env.STRAPI_URL}/api/jobs?populate[0]=technician&filters[status][$ne]=completed`,
    {
      headers: { Authorization: `Bearer ${jwt}` },
      next: { tags: ['jobs'] },
    }
  );

  if (!res.ok) throw new Error('Failed to fetch jobs');
  const { data }: { data: Job[] } = await res.json();

  return (
    <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 p-4">
      {data.map((job) => (
        <div key={job.documentId} className="bg-white rounded-lg shadow p-4">
          <h3 className="font-semibold text-lg">{job.title}</h3>
          <p className="text-sm text-gray-600">{job.serviceAddress}</p>
          <span className="inline-block mt-2 px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded">
            {job.status}
          </span>
          <p className="text-xs text-gray-400 mt-2">
            Updated {formatDistanceToNow(new Date(job.updatedAt), { addSuffix: true })}
          </p>
          <StatusActions jobId={job.documentId} currentStatus={job.status} />
        </div>
      ))}
    </div>
  );
}

date-fns ships its own TypeScript declarations, so you don't install @types/date-fns. formatDistanceToNow with addSuffix renders timestamps like "2 days ago" for the field crew.

The status update action advances a job and respects the same transition rules the backend enforces. Calling revalidatePath before any redirect matters, because redirect throws a control-flow exception that halts everything after it. Here there's no redirect, so a plain revalidation refreshes the cards:

// app/actions/status.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
import { getJwt } from '@/app/lib/auth';

const STRAPI_URL = process.env.STRAPI_URL;

export async function updateJobStatus(documentId: string, newStatus: string) {
  const jwt = await getJwt();
  if (!jwt) throw new Error('Unauthorized');

  const res = await fetch(`${STRAPI_URL}/api/jobs/${documentId}`, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${jwt}`,
    },
    body: JSON.stringify({ data: { status: newStatus } }),
  });

  if (!res.ok) throw new Error('Failed to update job status');

  revalidatePath('/my-jobs');
  revalidateTag('jobs');
}

export async function completeJob(documentId: string, notes: string) {
  const jwt = await getJwt();
  if (!jwt) throw new Error('Unauthorized');

  const res = await fetch(`${STRAPI_URL}/api/jobs/${documentId}`, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${jwt}`,
    },
    body: JSON.stringify({
      data: { status: 'completed', completionNotes: notes },
    }),
  });

  if (!res.ok) throw new Error('Failed to complete job');

  revalidatePath('/my-jobs');
  revalidateTag('jobs');
}

The Client Component maps the current status to the next valid action and shows the completion notes form when a job is in progress:

// app/(technician)/my-jobs/status-actions.tsx
'use client';
import { useState } from 'react';
import { updateJobStatus, completeJob } from '@/app/actions/status';

const NEXT_STATUS: Record<string, { label: string; value: string }> = {
  assigned: { label: 'Start travel (En route)', value: 'en_route' },
  en_route: { label: 'Begin work (In progress)', value: 'in_progress' },
};

export function StatusActions({
  jobId,
  currentStatus,
}: {
  jobId: string;
  currentStatus: string;
}) {
  const [notes, setNotes] = useState('');
  const next = NEXT_STATUS[currentStatus];

  if (currentStatus === 'in_progress') {
    return (
      <form
        action={async () => {
          await completeJob(jobId, notes);
        }}
        className="mt-3 flex flex-col gap-2"
      >
        <textarea
          value={notes}
          onChange={(e) => setNotes(e.target.value)}
          placeholder="Completion notes"
          className="border rounded p-2 text-sm"
        />
        <button className="bg-green-600 text-white rounded px-3 py-2 text-sm">
          Mark Completed
        </button>
      </form>
    );
  }

  if (!next) return null;

  return (
    <button
      onClick={() => updateJobStatus(jobId, next.value)}
      className="mt-3 bg-blue-600 text-white rounded px-3 py-2 text-sm w-full"
    >
      {next.label}
    </button>
  );
}

Step 4: Build the Client Service Portal

Clients submit requests and track their status. The submission form posts to a Server Action that creates a Service Request:

// app/actions/requests.ts
'use server';
import { revalidatePath } from 'next/cache';
import { getJwt } from '@/app/lib/auth';

const STRAPI_URL = process.env.STRAPI_URL;

export async function submitRequest(formData: FormData) {
  const jwt = await getJwt();
  if (!jwt) throw new Error('Unauthorized');

  const res = await fetch(`${STRAPI_URL}/api/service-requests`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${jwt}`,
    },
    body: JSON.stringify({
      data: {
        description: formData.get('description'),
        address: formData.get('address'),
        latitude: Number(formData.get('latitude')),
        longitude: Number(formData.get('longitude')),
        preferredDate: formData.get('preferredDate'),
        status: 'pending',
      },
    }),
  });

  if (!res.ok) throw new Error('Failed to submit request');

  revalidatePath('/requests');
}

The portal page renders the form and a list of the client's jobs with a status timeline. Forms calling Server Actions submit even before JavaScript loads, thanks to progressive enhancement. Validate the client's JWT server-side and enforce backend policies or server-side filters based on the authenticated user so clients only see their own work.

// app/(client)/requests/page.tsx
import 'server-only';
import { getJwt } from '@/app/lib/auth';
import { format } from 'date-fns';
import { submitRequest } from '@/app/actions/requests';

interface Job {
  documentId: string;
  title: string;
  status: string;
  scheduledAt: string;
  technician?: { name: string };
}

export default async function RequestsPage() {
  const jwt = await getJwt();

  const meRes = await fetch(`${process.env.STRAPI_URL}/api/users/me`, {
    headers: { Authorization: `Bearer ${jwt}` },
  });
  const { id: clientId } = await meRes.json();

  const res = await fetch(
    `${process.env.STRAPI_URL}/api/jobs?populate[0]=technician&filters[client][documentId][$eq]=${clientId}`,
    {
      headers: { Authorization: `Bearer ${jwt}` },
      next: { tags: ['jobs'] },
    }
  );

  const { data }: { data: Job[] } = await res.json();

  return (
    <div className="p-4 max-w-2xl mx-auto">
      <h1 className="text-2xl font-semibold mb-4">Service Portal</h1>

      <form action={submitRequest} className="flex flex-col gap-2 mb-8">
        <textarea
          name="description"
          placeholder="Describe the issue"
          className="border rounded p-2"
          required
        />
        <input
          name="address"
          placeholder="Service address"
          className="border rounded p-2"
          required
        />
        <input
          name="latitude"
          placeholder="Latitude"
          type="number"
          step="any"
          className="border rounded p-2"
          required
        />
        <input
          name="longitude"
          placeholder="Longitude"
          type="number"
          step="any"
          className="border rounded p-2"
          required
        />
        <input
          name="preferredDate"
          type="datetime-local"
          className="border rounded p-2"
        />
        <button className="bg-blue-600 text-white rounded px-4 py-2">
          Submit Request
        </button>
      </form>

      <h2 className="text-xl font-semibold mb-3">My Jobs</h2>
      <ul className="flex flex-col gap-3">
        {data.map((job) => (
          <li key={job.documentId} className="border rounded p-3">
            <p className="font-medium">{job.title}</p>
            <p className="text-sm text-blue-700">Status: {job.status}</p>
            {job.technician && (
              <p className="text-sm text-gray-600">
                Technician: {job.technician.name}
              </p>
            )}
            {job.scheduledAt && (
              <p className="text-sm text-gray-500">
                Scheduled: {format(new Date(job.scheduledAt), 'PPpp')}
              </p>
            )}
          </li>
        ))}
      </ul>
    </div>
  );
}

date-fns format with the PPpp token renders a scheduled date like "Jun 15, 2026 at 10:30 AM".

Putting It All Together

Run both servers: npm run develop in the Strapi project and npm run dev in the Next.js project. Walk the full lifecycle with all three roles.

A client logs into the portal at /requests and submits a service request with the address coordinates for a broken HVAC unit. The request lands in Strapi with status pending.

The dispatcher opens /jobs and sees the new job. They focus the assignment dropdown, which calls the findNearby endpoint. The custom service queries available technicians, runs the Haversine calculation against the job coordinates, and returns the list sorted by distance with each technician's distance in kilometers. The dispatcher picks the closest one. The assignTechnician action connects the technician and sets status to assigned. The transition middleware confirms submitted → assigned is valid and lets it through.

The technician opens /my-jobs on their phone and sees the assigned card. They tap "Start travel," which advances status to en_route. The middleware permits assigned → en_route. This fires the entry.update webhook, so any connected notification system can alert the client that help is on the way. On arrival, the technician taps "Begin work" (en_route → in_progress), does the repair, fills in completion notes, and taps "Mark Completed" (in_progress → completed).

Each tap in that sequence writes a single Document Service update, and each update passes through the transition middleware before it commits. A dispatcher watching the same job from the /jobs table sees the status column move in step, because both dashboards read the same tagged fetch. When the day winds down, completed jobs drop off the technician's active card grid thanks to the filters[status][$ne]=completed query, keeping the field view focused on work that still needs attention.

Throughout, the client's portal reflects each change. The revalidateTag('jobs') call in every mutation invalidates the tagged fetches, so the next render shows the current status, the assigned technician's name, and the scheduled time. If anyone tries an illegal jump, like marking a submitted job completed directly, the middleware throws before the database is touched.

Next Steps

You have a working dispatch system. From here, a few directions are worth exploring:

  • Deploy the backend to Strapi Cloud with a managed PostgreSQL database, or weigh the other deployment options if you self-host.
  • Add a map view with Leaflet to plot job and technician coordinates visually.
  • Integrate SMS notifications by pointing the status-change webhook at a messaging provider.
  • Build a scheduling calendar for dispatchers using the scheduledAt field and date-fns formatting.
  • Add photo upload to the completion form for job evidence using Strapi's Media Library.
  • More walkthroughs live on the Strapi blog.

For deeper reference, the Strapi documentation covers the Document Service API and REST relations in full, and the Next.js documentation details Server Actions and caching.

Paul BratslavskyDeveloper Advocate
How to Build a Translation Memory and Localization Workflow Tool with Strapi 5
Ecosystem·16 min read

How to Build a Translation Memory and Localization Workflow Tool with Strapi 5

Learn to build a translation memory and localization workflow tool with Strapi 5 using custom content-types, fuzzy matching, lifecycle hooks, and REST API.

·July 29, 2026
How to Build Your Personal Library and Book Catalog System Using Strapi 5
Ecosystem·15 min read

How to Build Your Personal Library and Book Catalog System Using Strapi 5

Build a personal library catalog with Strapi 5. Define collection types, seed data via Document Service API, and display books in a vanilla JS frontend.

July 29, 2026
Build an e-Signature Platform with Strapi 5 and React
Ecosystem·16 min read

How to Build an e-Signature Platform with Strapi 5 and React

Build a working e-signature platform with Strapi 5 and React. Capture signatures on canvas, upload via REST API, and track document status end-to-end.

·July 29, 2026