Construction projects fail in the gaps between roles. A project manager updates a schedule that a contractor never sees, an inspection report gets buried in someone's inbox, and a task gets marked "done" before anyone actually inspected the work. The fix is a single source of truth where each role sees exactly what they need and nothing they shouldn't.
This tutorial walks through building a construction project management portal with Strapi and TanStack Start. Strapi 5 handles the content model, role-based access control, document storage, and task status workflows. TanStack Start powers the authenticated frontend with type-safe routing and data fetching against Strapi's REST API. By the end, four distinct roles (Project Manager, Site Supervisor, Contractor, and Client) each get a dashboard scoped to their permissions.
We're using TanStack Start because it appears on Strapi's homepage as a highlighted frontend integration. Strapi also maintains a TanStack integration page, and the blog already has an inventory management tutorial you can cross-reference for more TanStack Start patterns.
In brief:
- Model projects, tasks, team members, and documents as related Collection Types in Strapi 5
- Configure four custom roles with granular permissions through the Users & Permissions plugin
- Enforce task status transition rules with a Document Service middleware
- Handle blueprint and permit uploads through the Media Library and consume them with TanStack Start
What We're Building
The portal centers on a relational content model: projects own tasks and documents, team members connect to projects and tasks through many-to-many relations, and every document links back to a specific project. On top of that model, Strapi's RBAC governs who can read or write each Content Type.
A Project Manager gets full access: create projects, assign tasks, upload contracts. A Site Supervisor reads project data and updates task status. A Contractor sees only assigned tasks. A Client gets read-only visibility into progress and approved documents. The frontend reads the same flat REST responses and renders a role-appropriate view.
The relational model is what makes governance possible. When a project owns its tasks and documents through explicit relations, every permission check has a clear boundary to enforce. A Contractor querying tasks never sees a project record, because the project is a separate Content Type with its own permission gate.
A Client querying documents only receives the files attached to projects they can read. Without those relations, you would be filtering flat lists in application code and hoping you covered every edge case. With them, Strapi resolves access at the data layer before a response ever leaves the server, which is the difference between a portal that leaks data and one an inspector can trust.
What you'll learn:
- Content Type modeling for projects, tasks, team members, and documents
- Configuring four custom RBAC roles in the Users & Permissions plugin
- Building task status transition validation with a Document Service middleware
- Handling document uploads via the Media Library
- Building the dashboard with TanStack Start, TanStack Router, and TanStack Query
Prerequisites
Pin these versions. The JavaScript ecosystem moves fast, and mixing majors is where things break in production.
- Node.js v22 LTS (supported LTS; do not use v18, which is no longer in the supported LTS range)
- Strapi 5.x (verify the latest stable with
npx create-strapi@latest; this tutorial was written against 5.47.1) - TanStack Start latest Release Candidate (v1.0 RC; Start is currently in RC status)
- TanStack Router (ships with Start)
- TanStack Query v5.x
- Tailwind CSS v4.x
- PostgreSQL 16.x (within Strapi's supported range of 14.0 to 17.0)
- Basic familiarity with TypeScript interfaces, frontend development, and REST APIs
- A code editor and terminal
A quick vocabulary note before we start. LTS means Long Term Support. CRUD means Create, Read, Update, Delete. RBAC means Role-Based Access Control. You'll see all three throughout.
Setting Up the Strapi Backend
The backend work breaks into five steps: installing Strapi, defining the content model, configuring roles, adding transition logic, and setting up file uploads. Each step builds on the previous one, so follow the order.
Step 1: Install Strapi 5
Run the official installer.
npx create-strapi@latest construction-portalThe CLI prompts you for configuration. Choose PostgreSQL when asked for a database. When a new project is created, DATABASE_CLIENT=postgres and the connection variables are written to .env automatically.
Your config/database.js reads from those environment variables. Confirm it matches:
// config/database.js
module.exports = ({ env }) => ({
connection: {
client: 'postgres',
connection: {
connectionString: env('DATABASE_URL'),
host: env('DATABASE_HOST', '127.0.0.1'),
port: env.int('DATABASE_PORT', 5432),
database: env('DATABASE_NAME', 'strapi'),
user: env('DATABASE_USERNAME', 'strapi'),
password: env('DATABASE_PASSWORD', 'strapi'),
schema: env('DATABASE_SCHEMA', 'public'),
ssl: env.bool('DATABASE_SSL', false) && {
rejectUnauthorized: env.bool('DATABASE_SSL_SELF', false),
},
},
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
debug: false,
},
});One thing that bites people: the PostgreSQL user needs SCHEMA permissions. A new user without them throws a 500 error when the Admin Panel loads. Grant the permission before you start the server:
cd construction-portal
npm run developCreate your admin account at http://localhost:1337/admin.
Step 2: Define the Project, Task, Team Member, and Document Content Types
You can build these in the Content Type Builder, but writing the schemas directly is faster and reviewable. Schema files live at ./src/api/[api-name]/content-types/[content-type-name]/schema.json. The singularName and pluralName inside info must be kebab-case, and relation targets use the api::api-name.content-type-name format. These conventions are documented in the Strapi models reference.
The project owns tasks and documents and connects to team members.
// src/api/project/content-types/project/schema.json
{
"kind": "collectionType",
"collectionName": "projects",
"info": {
"singularName": "project",
"pluralName": "projects",
"displayName": "Project"
},
"options": { "draftAndPublish": true },
"pluginOptions": {},
"attributes": {
"title": { "type": "string", "required": true },
"description": { "type": "richtext" },
"status": {
"type": "enumeration",
"enum": ["planning", "active", "on_hold", "completed", "cancelled"],
"required": true
},
"tasks": {
"type": "relation",
"relation": "oneToMany",
"target": "api::task.task",
"mappedBy": "project"
},
"team_members": {
"type": "relation",
"relation": "manyToMany",
"target": "api::team-member.team-member",
"mappedBy": "projects"
},
"documents": {
"type": "relation",
"relation": "oneToMany",
"target": "api::document.document",
"mappedBy": "project"
}
}
}Tasks carry the status and priority enumerations that drive the workflow.
// src/api/task/content-types/task/schema.json
{
"kind": "collectionType",
"collectionName": "tasks",
"info": {
"singularName": "task",
"pluralName": "tasks",
"displayName": "Task"
},
"options": { "draftAndPublish": false },
"pluginOptions": {},
"attributes": {
"title": { "type": "string", "required": true },
"description": { "type": "text" },
"status": {
"type": "enumeration",
"enum": ["todo", "in_progress", "in_review", "done", "cancelled"],
"required": true
},
"priority": {
"type": "enumeration",
"enum": ["low", "medium", "high", "critical"],
"required": true
},
"due_date": { "type": "date" },
"project": {
"type": "relation",
"relation": "manyToOne",
"target": "api::project.project",
"inversedBy": "tasks"
},
"assignees": {
"type": "relation",
"relation": "manyToMany",
"target": "api::team-member.team-member",
"mappedBy": "assigned_tasks"
}
}
}Team members link to both projects and tasks. Note the role enumeration, which includes values like admin, manager, developer, designer, and viewer.
// src/api/team-member/content-types/team-member/schema.json
{
"kind": "collectionType",
"collectionName": "team_members",
"info": {
"singularName": "team-member",
"pluralName": "team-members",
"displayName": "Team Member"
},
"options": { "draftAndPublish": false },
"pluginOptions": {},
"attributes": {
"name": { "type": "string", "required": true },
"email": { "type": "email", "required": true },
"role": {
"type": "enumeration",
"enum": ["admin", "manager", "developer", "designer", "viewer"],
"required": true
},
"projects": {
"type": "relation",
"relation": "manyToMany",
"target": "api::project.project",
"inversedBy": "team_members"
},
"assigned_tasks": {
"type": "relation",
"relation": "manyToMany",
"target": "api::task.task",
"inversedBy": "assignees"
}
}
}Documents hold the media field for uploads and link back to one project. The document_type enumeration covers the records that show up on real sites: specifications, reports, contracts, and so on.
// src/api/document/content-types/document/schema.json
{
"kind": "collectionType",
"collectionName": "documents",
"info": {
"singularName": "document",
"pluralName": "documents",
"displayName": "Document"
},
"options": { "draftAndPublish": true },
"pluginOptions": {},
"attributes": {
"title": { "type": "string", "required": true },
"document_type": {
"type": "enumeration",
"enum": ["specification", "report", "contract", "invoice", "meeting_notes", "other"],
"required": true
},
"file": {
"type": "media",
"multiple": false,
"allowedTypes": ["files", "images", "videos", "audios"]
},
"project": {
"type": "relation",
"relation": "manyToOne",
"target": "api::project.project",
"inversedBy": "documents"
}
}
}Restart the server so Strapi picks up the new schemas. Multi relations (oneToMany, manyToMany) are persisted and returned as arrays, which matters when you write TypeScript types later.
Step 3: Configure Role-Based Access Control
Strapi 5 has two separate permission systems. The RBAC feature under Settings > Administration panel > Roles governs administrators who log into the Admin Panel. The Users & Permissions plugin under Settings > Users & Permissions plugin > Roles governs end users who consume the API. Your four construction roles control API access, so use the Users & Permissions plugin.
Each role maps to a real responsibility on a job site, and the permissions follow from that responsibility. A Project Manager is typically responsible for managing the schedule and monitoring the budget, but permissions to create projects, assign tasks, or upload contracts depend on the organization's processes and system configuration.
A Site Supervisor is accountable for what actually happens on the ground, so they read everything and move tasks through the workflow, but they never spin up new projects or rewrite scope. A Contractor is hired for specific work, so they see only the tasks assigned to them and the documents they need to do the job. A Client pays the bills and wants visibility, so they get read-only access to progress and approved documents and nothing else. Modeling permissions this way keeps the portal honest: nobody can act outside their role because the API refuses the request before the frontend ever renders a button.
By default, the plugin ships two roles: Authenticated for logged-in users and Public for unauthenticated requests. Requests without a token assume the public role, and auth failures return 401 (unauthorized).
To create the Project Manager role, navigate to Settings > Users & Permissions plugin > Roles:
- Click Add new role.
- Fill in Name ("Project Manager") and a Description.
- Under Permissions, click Collection types.
- Tick the checkbox left of
Project,Task,Team-member, andDocumentto grant access. All actions are enabled by default. - Leave every CRUD action ticked for the Project Manager. They get full access.
- Click Save.
Repeat for the other three, restricting actions as you go:
- Site Supervisor:
findandfindOneon every Content Type, plusupdateonTask. They monitor progress and move tasks along but don't create projects. - Contractor:
findandfindOneonTaskandDocument, plusupdateonTask. To restrict which fields they touch, click the Content Type name to expand field-level permissions and untick anything they shouldn't write. - Client:
findandfindOneonProjectandDocumentonly. Read-only visibility.
Each end user gets exactly one role at a time. The default role assigned to new users is configured under Advanced settings in the plugin.
One production note: since v5.24.0, Strapi stores admin authentication in secure HTTP-only cookies, and browsers only accept those over HTTPS. Local development works fine over plain HTTP, but your production deployment needs TLS or logins silently fail.
Step 4: Build Task Status Transition Logic
Here's where the workflow rules live. A task shouldn't jump from todo straight to done without passing through review, and a cancelled task shouldn't reopen. In Strapi 5, the right tool for this is a Document Service middleware, not a lifecycle hook.
Why? Creating a published document fires afterCreate and beforeCreate lifecycle hooks twice, because the published version is immutable and Strapi keeps a draft alongside it. When you run npx @strapi/upgrade major, the migration even comments out database lifecycle hooks by default with a warning. Document Service middlewares operate at the method level (create, update, publish) and can cover multiple Content Types and actions with one block of code.
Register the middleware in the register() lifecycle hook in src/index.ts. Registration timing matters: it has to be register(), not bootstrap().
// src/index.ts
type StatusValue =
| 'todo'
| 'in_progress'
| 'in_review'
| 'done'
| 'cancelled';
const allowedTransitions: Record<StatusValue, StatusValue[]> = {
todo: ['in_progress', 'cancelled'],
in_progress: ['in_review', 'cancelled'],
in_review: ['in_progress', 'done', 'cancelled'],
done: [],
cancelled: [],
};
export default {
register({ strapi }: { strapi: any }) {
strapi.documents.use(async (context: any, next: any) => {
if (context.uid !== 'api::task.task' || context.action !== 'update') {
return next();
}
const nextStatus: StatusValue | undefined = context.params?.data?.status;
if (!nextStatus) {
return next();
}
const existing = await strapi.documents('api::task.task').findOne({
documentId: context.params.documentId,
fields: ['status'],
});
if (!existing) {
return next();
}
const currentStatus = existing.status as StatusValue;
if (currentStatus === nextStatus) {
return next();
}
const permitted = allowedTransitions[currentStatus] ?? [];
if (!permitted.includes(nextStatus)) {
throw new Error(
`Invalid status transition: "${currentStatus}" cannot move to "${nextStatus}".`
);
}
return next();
});
},
bootstrap() {},
};The context object carries uid (for example api::task.task), action (create, update, delete, publish), and params (which includes data, documentId, status, and populate). You read context.params.data before calling next() and throw an error to block the operation.
Notice the use of the Document Service API via strapi.documents() to fetch the current status, identified by documentId rather than a numeric id. The Entity Service API from v4 is deprecated, so all database access goes through the Document Service.
Step 5: Configure Document Uploads
Strapi's Media Library handles file storage, and the Upload package exposes /api/upload endpoints. Construction documents get large. Blueprints and high-resolution inspection photos blow past the default 200MB limit, and the fix needs two layers. The Upload plugin's sizeLimit and the body parser's maxFileSize both have to be raised.
Layer one is the Upload plugin config:
// config/plugins.js
module.exports = ({ env }) => ({
upload: {
config: {
sizeLimit: 250 * 1024 * 1024, // 250 MB in bytes
},
},
});Layer two is the body parser middleware. Configure the body middleware settings in your middlewares array with a matching maxFileSize:
// config/middlewares.js
module.exports = [
'strapi::logger',
'strapi::errors',
'strapi::security',
'strapi::cors',
'strapi::poweredBy',
'strapi::query',
{
name: 'strapi::body',
config: {
formLimit: '256mb',
jsonLimit: '256mb',
textLimit: '256mb',
formidable: {
maxFileSize: 250 * 1024 * 1024,
},
},
},
'strapi::session',
'strapi::favicon',
'strapi::public',
];If a reverse proxy sits in front of Strapi, raise its limit too. Nginx defaults client_max_body_size to 1MB, which will reject uploads long before they reach Strapi. By default files land in public/uploads/, and the Upload plugin supports Amazon S3 and Cloudinary providers when you're ready to move storage off the application server.
Building the TanStack Start Frontend
With the backend in place, the frontend needs four things: a project scaffold, an auth layer, route-level data loading, and a document upload flow. Each step connects to the Strapi REST API through the types and helpers you define along the way.
Step 1: Set Up the TanStack Start Project
Scaffold a new project with the TanStack CLI.
npx @tanstack/cli create construction-frontend
cd construction-frontend
cp .env.example .envSet the Strapi API URL in .env. TanStack Start client components access only PUBLIC_-prefixed variables in client code.
# .env
PUBLIC_STRAPI_URL=http://localhost:1337Confirm Tailwind v4 is wired through the Vite plugin. Version 4 needs no tailwind.config.js, no PostCSS, and no autoprefixer. Your vite.config.ts registers the plugin directly.
// vite.config.ts
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
tailwindcss(),
tanstackStart(),
viteReact(),
],
})Your stylesheet imports Tailwind in a single line, per the Tailwind v4 install guide:
/* src/styles/app.css */
@import "tailwindcss";Start the dev server:
pnpm dev
# Open http://localhost:3000Step 2: Configure Authentication and Protected Routes
Strapi authenticates against /api/auth/local, which accepts an identifier (email or username) and password, then returns a JWT and a bare user object. The permissions REST API returns the user without a data wrapper, unlike standard content endpoints. Keep that in mind when typing the response.
Start with the TypeScript types and a small API helper. The flat REST format means attributes sit directly on the object, no data.attributes nesting.
// src/lib/strapi.ts
const STRAPI_URL = import.meta.env.PUBLIC_STRAPI_URL as string
export interface StrapiUser {
id: number
documentId: string
username: string
email: string
confirmed: boolean
blocked: boolean
}
export interface LoginResponse {
jwt: string
user: StrapiUser
}
export interface StrapiListResponse<T> {
data: T[]
meta: {
pagination: { page: number; pageSize: number; pageCount: number; total: number }
}
}
export async function login(
identifier: string,
password: string
): Promise<LoginResponse> {
const res = await fetch(`${STRAPI_URL}/api/auth/local`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier, password }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body?.error?.message ?? 'Login failed')
}
return res.json()
}
export async function strapiFetch<T>(
path: string,
token: string | null
): Promise<T> {
const res = await fetch(`${STRAPI_URL}${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (res.status === 401) {
throw new Error('Unauthorized')
}
if (!res.ok) {
throw new Error(`Request failed: ${res.status}`)
}
return res.json()
}Hold the token and user in an auth context. For a real deployment you'd move the token into an HTTP-only cookie, but localStorage keeps the example readable.
// src/lib/auth.tsx
import { createContext, useContext, useState, type ReactNode } from 'react'
import { login as loginRequest, type StrapiUser } from './strapi'
interface AuthState {
user: StrapiUser | null
token: string | null
isAuthenticated: boolean
signIn: (identifier: string, password: string) => Promise<void>
signOut: () => void
}
const AuthContext = createContext<AuthState | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(() =>
typeof window !== 'undefined' ? localStorage.getItem('jwt') : null
)
const [user, setUser] = useState<StrapiUser | null>(() => {
if (typeof window === 'undefined') return null
const raw = localStorage.getItem('user')
return raw ? (JSON.parse(raw) as StrapiUser) : null
})
async function signIn(identifier: string, password: string) {
const result = await loginRequest(identifier, password)
setToken(result.jwt)
setUser(result.user)
localStorage.setItem('jwt', result.jwt)
localStorage.setItem('user', JSON.stringify(result.user))
}
function signOut() {
setToken(null)
setUser(null)
localStorage.removeItem('jwt')
localStorage.removeItem('user')
}
return (
<AuthContext.Provider
value={{ user, token, isAuthenticated: !!token, signIn, signOut }}
>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
}Protect routes with a pathless layout route. TanStack Router's beforeLoad runs before any child route loads and acts as middleware for the whole subtree. Throwing a redirect there stops every child from rendering. This pattern comes straight from the authenticated routes guide.
// src/routes/_authenticated.tsx
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated')({
beforeLoad: ({ context, location }) => {
if (!context.auth.isAuthenticated) {
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
},
component: () => <Outlet />,
})Use location.href for the redirect value, not router.state.resolvedLocation, which can lag behind the actual location. One security caveat worth internalizing: a route guard handles UX redirects, but it does not protect a server function. Server functions are reachable by POST regardless of which route renders them, so pair routing-side guards with handler-level auth checks.
Step 3: Create the Project Overview and Task Board
Consume the Strapi REST API. Remember three v5 rules: responses are flat, you reference documents by documentId, and population is explicit (no populate=* in production). Define the types against the flat shape.
// src/lib/types.ts
export interface Task {
id: number
documentId: string
title: string
description?: string
status: 'todo' | 'in_progress' | 'in_review' | 'done' | 'cancelled'
priority: 'low' | 'medium' | 'high' | 'critical'
due_date?: string
}
export interface Project {
id: number
documentId: string
title: string
description?: string
status: 'planning' | 'active' | 'on_hold' | 'completed' | 'cancelled'
tasks?: Task[]
}Set up TanStack Query options and integrate them with the route loader. The loader calls ensureQueryData, which returns cached data without refetching unless it's missing. That's the recommended pattern from the external data loading guide.
// src/routes/_authenticated/projects.tsx
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { strapiFetch, type StrapiListResponse } from '../../lib/strapi'
import type { Project } from '../../lib/types'
const projectsQueryOptions = (token: string | null) =>
queryOptions({
queryKey: ['projects'],
queryFn: () =>
strapiFetch<StrapiListResponse<Project>>(
'/api/projects?populate=tasks',
token
),
})
export const Route = createFileRoute('/_authenticated/projects')({
loader: ({ context }) =>
context.queryClient.ensureQueryData(
projectsQueryOptions(context.auth.token)
),
component: ProjectsPage,
})
const statusColors: Record<Project['status'], string> = {
planning: 'bg-gray-100 text-gray-800',
active: 'bg-green-100 text-green-800',
on_hold: 'bg-yellow-100 text-yellow-800',
completed: 'bg-blue-100 text-blue-800',
cancelled: 'bg-red-100 text-red-800',
}
function ProjectsPage() {
const { auth } = Route.useRouteContext()
const { data } = useSuspenseQuery(projectsQueryOptions(auth.token))
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">Projects</h1>
<div className="grid gap-4 md:grid-cols-3">
{data.data.map((project) => (
<div
key={project.documentId}
className="rounded-lg border p-4 shadow-sm"
>
<h2 className="font-semibold">{project.title}</h2>
<span
className={`mt-2 inline-block rounded px-2 py-1 text-xs ${statusColors[project.status]}`}
>
{project.status}
</span>
<p className="mt-2 text-sm text-gray-600">
{project.tasks?.length ?? 0} tasks
</p>
</div>
))}
</div>
</div>
)
}For a single project's task board, group tasks by status. The route reads projectId from params and populates tasks explicitly.
// src/routes/_authenticated/projects.$projectId.tsx
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { strapiFetch } from '../../lib/strapi'
import type { Project, Task } from '../../lib/types'
interface SingleProjectResponse {
data: Project
}
const projectQueryOptions = (documentId: string, token: string | null) =>
queryOptions({
queryKey: ['project', documentId],
queryFn: () =>
strapiFetch<SingleProjectResponse>(
`/api/projects/${documentId}?populate=tasks`,
token
),
})
export const Route = createFileRoute('/_authenticated/projects/$projectId')({
loader: ({ context, params }) =>
context.queryClient.ensureQueryData(
projectQueryOptions(params.projectId, context.auth.token)
),
component: TaskBoard,
})
const columns: Task['status'][] = ['todo', 'in_progress', 'in_review', 'done', 'cancelled']
function TaskBoard() {
const { auth } = Route.useRouteContext()
const { projectId } = Route.useParams()
const { data } = useSuspenseQuery(
projectQueryOptions(projectId, auth.token)
)
const tasks = data.data.tasks ?? []
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">{data.data.title}</h1>
<div className="grid gap-4 md:grid-cols-4">
{columns.map((status) => (
<div key={status} className="rounded-lg bg-gray-50 p-3">
<h3 className="mb-2 text-sm font-semibold uppercase">{status}</h3>
{tasks
.filter((task) => task.status === status)
.map((task) => (
<div
key={task.documentId}
className="mb-2 rounded border bg-white p-2 text-sm"
>
<p className="font-medium">{task.title}</p>
<span className="text-xs text-gray-500">{task.priority}</span>
</div>
))}
</div>
))}
</div>
</div>
)
}Step 4: Build the Document Library View
The document library lists files with download links and an upload form. Strapi's /api/upload endpoint accepts direct file uploads, and you can link those files to entries during creation using the file ID returned from the upload. This approach lets you attach a file and create a document in one workflow.
// src/lib/documents.ts
const STRAPI_URL = import.meta.env.PUBLIC_STRAPI_URL as string
export interface StrapiFile {
id: number
documentId: string
name: string
url: string
}
export interface ProjectDocument {
id: number
documentId: string
title: string
document_type: string
file?: StrapiFile
}
interface UploadedFile {
id: number
}
export async function uploadDocument(
token: string,
projectDocumentId: string,
title: string,
documentType: string,
file: File
): Promise<void> {
const formData = new FormData()
formData.append('files', file)
const uploadRes = await fetch(`${STRAPI_URL}/api/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData,
})
if (!uploadRes.ok) {
throw new Error('File upload failed')
}
const uploaded = (await uploadRes.json()) as UploadedFile[]
const fileId = uploaded[0].id
const createRes = await fetch(`${STRAPI_URL}/api/documents`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
data: {
title,
document_type: documentType,
file: fileId,
project: { connect: [projectDocumentId] },
},
}),
})
if (!createRes.ok) {
throw new Error('Document creation failed')
}
}Notice the connect syntax on the project relation. Strapi 5 uses connect to attach relations on create and update. The file field takes the uploaded file's ID directly.
Now the view with a filterable list and an upload form wired through a mutation:
// src/routes/_authenticated/documents.tsx
import { useState } from 'react'
import {
queryOptions,
useSuspenseQuery,
useMutation,
useQueryClient,
} from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { strapiFetch, type StrapiListResponse } from '../../lib/strapi'
import {
uploadDocument,
type ProjectDocument,
} from '../../lib/documents'
const STRAPI_URL = import.meta.env.PUBLIC_STRAPI_URL as string
const documentsQueryOptions = (token: string | null) =>
queryOptions({
queryKey: ['documents'],
queryFn: () =>
strapiFetch<StrapiListResponse<ProjectDocument>>(
'/api/documents?populate=file',
token
),
})
export const Route = createFileRoute('/_authenticated/documents')({
loader: ({ context }) =>
context.queryClient.ensureQueryData(
documentsQueryOptions(context.auth.token)
),
component: DocumentLibrary,
})
function DocumentLibrary() {
const { auth } = Route.useRouteContext()
const queryClient = useQueryClient()
const { data } = useSuspenseQuery(documentsQueryOptions(auth.token))
const [filter, setFilter] = useState('all')
const mutation = useMutation({
mutationFn: (form: FormData) =>
uploadDocument(
auth.token as string,
form.get('projectId') as string,
form.get('title') as string,
form.get('documentType') as string,
form.get('file') as File
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents'] })
},
})
const docs =
filter === 'all'
? data.data
: data.data.filter((doc) => doc.document_type === filter)
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">Document Library</h1>
<select
className="mb-4 rounded border p-2"
value={filter}
onChange={(e) => setFilter(e.target.value)}
>
<option value="all">All types</option>
<option value="specification">Specification</option>
<option value="report">Report</option>
<option value="contract">Contract</option>
<option value="invoice">Invoice</option>
</select>
<form
className="mb-6 space-y-2"
onSubmit={(e) => {
e.preventDefault()
mutation.mutate(new FormData(e.currentTarget))
}}
>
<input name="title" placeholder="Title" required className="block rounded border p-2" />
<input name="projectId" placeholder="Project documentId" required className="block rounded border p-2" />
<select name="documentType" className="block rounded border p-2">
<option value="specification">Specification</option>
<option value="report">Report</option>
<option value="contract">Contract</option>
</select>
<input name="file" type="file" required className="block" />
<button
type="submit"
disabled={mutation.isPending}
className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{mutation.isPending ? 'Uploading...' : 'Upload'}
</button>
{mutation.isError && (
<p className="text-sm text-red-600">{mutation.error.message}</p>
)}
</form>
<ul className="space-y-2">
{docs.map((doc) => (
<li key={doc.documentId} className="flex justify-between rounded border p-3">
<span>
{doc.title}{' '}
<span className="text-xs text-gray-500">({doc.document_type})</span>
</span>
{doc.file && (
<a
href={`${STRAPI_URL}${doc.file.url}`}
className="text-blue-600 underline"
target="_blank"
rel="noreferrer"
>
Download
</a>
)}
</li>
))}
</ul>
</div>
)
}The mutation invalidates the documents query on success, so TanStack Query refetches and the new file appears without a manual reload. Query invalidation only refetches queries whose keys match.
Putting It All Together
Start both servers: Strapi on 1337, TanStack Start on 3000. Create four end users in the Admin Panel under Content Manager > User, assigning each a different role.
Log in as the Project Manager. Their JWT carries the role that grants full CRUD on every Content Type. They create a project, populate it with tasks (each starting at todo), and upload a contract through the document form. Protected requests carry Authorization: Bearer <jwt>, and Strapi applies the authenticated user's role-based permissions before responding.
Now log in as the Contractor. The same /api/projects request is rejected because the Contractor role has no find permission on Project. Their /api/tasks request succeeds but only exposes the fields you left ticked. The UI renders a task board with no project creation controls, because the data isn't reachable.
Finally, watch the transition middleware reject an invalid move. As the Site Supervisor, attempt to update a task directly from todo to done:
curl -X PUT http://localhost:1337/api/tasks/<documentId> \
-H "Authorization: Bearer <supervisor-jwt>" \
-H "Content-Type: application/json" \
-d '{"data": {"status": "done"}}'The Document Service middleware fetches the current status (todo), checks the allowedTransitions map, finds done isn't permitted from todo, and throws. The response is an error, and the task stays put. Move it to in_progress first, then in_review, then done, and each step passes. That's your workflow rule enforced at the data layer, where no frontend can bypass it.
How Strapi Powers This
Every feature in this portal maps to a Strapi capability. The Content Type Builder defines the relational model that keeps projects, tasks, team members, and documents connected. The Users & Permissions plugin scopes four roles to exactly the CRUD actions each job-site responsibility requires.
Document Service middlewares enforce status transition rules at the API layer, so no client can skip review steps. The Media Library stores blueprints, contracts, and inspection photos with configurable size limits and optional cloud provider support.
Strapi's flat REST responses give TanStack Start a predictable, type-safe contract to build against, and documentId routing keeps every query and mutation consistent from frontend to database. The result is a backend that governs access, validates workflows, and serves content without requiring custom server code for any of it.
Next Steps
You have a working portal. From here, a few directions make sense:
- Deploy Strapi to Strapi Cloud and point
PUBLIC_STRAPI_URLat the production endpoint - Add a Gantt-style timeline view to visualize project scheduling against
due_date - Integrate email notifications via Strapi webhooks when a task hits
in_review - Add audit logging on the transition middleware for compliance tracking, grounded in standards like ISO 19650-1:2018 for information management
- Dig deeper in the Strapi 5 documentation and the TanStack Start docs
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.