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:
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:
Pin these versions. The JavaScript ecosystem moves fast, and mixing majors is where things break in production.
npx create-strapi@latest; this tutorial was written against 5.47.1)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.
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.
Run the official installer.
1npx 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:
1// config/database.js
2module.exports = ({ env }) => ({
3 connection: {
4 client: 'postgres',
5 connection: {
6 connectionString: env('DATABASE_URL'),
7 host: env('DATABASE_HOST', '127.0.0.1'),
8 port: env.int('DATABASE_PORT', 5432),
9 database: env('DATABASE_NAME', 'strapi'),
10 user: env('DATABASE_USERNAME', 'strapi'),
11 password: env('DATABASE_PASSWORD', 'strapi'),
12 schema: env('DATABASE_SCHEMA', 'public'),
13 ssl: env.bool('DATABASE_SSL', false) && {
14 rejectUnauthorized: env.bool('DATABASE_SSL_SELF', false),
15 },
16 },
17 pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
18 debug: false,
19 },
20});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:
1cd construction-portal
2npm run developCreate your admin account at http://localhost:1337/admin.
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.
1// src/api/project/content-types/project/schema.json
2{
3 "kind": "collectionType",
4 "collectionName": "projects",
5 "info": {
6 "singularName": "project",
7 "pluralName": "projects",
8 "displayName": "Project"
9 },
10 "options": { "draftAndPublish": true },
11 "pluginOptions": {},
12 "attributes": {
13 "title": { "type": "string", "required": true },
14 "description": { "type": "richtext" },
15 "status": {
16 "type": "enumeration",
17 "enum": ["planning", "active", "on_hold", "completed", "cancelled"],
18 "required": true
19 },
20 "tasks": {
21 "type": "relation",
22 "relation": "oneToMany",
23 "target": "api::task.task",
24 "mappedBy": "project"
25 },
26 "team_members": {
27 "type": "relation",
28 "relation": "manyToMany",
29 "target": "api::team-member.team-member",
30 "mappedBy": "projects"
31 },
32 "documents": {
33 "type": "relation",
34 "relation": "oneToMany",
35 "target": "api::document.document",
36 "mappedBy": "project"
37 }
38 }
39}Tasks carry the status and priority enumerations that drive the workflow.
1// src/api/task/content-types/task/schema.json
2{
3 "kind": "collectionType",
4 "collectionName": "tasks",
5 "info": {
6 "singularName": "task",
7 "pluralName": "tasks",
8 "displayName": "Task"
9 },
10 "options": { "draftAndPublish": false },
11 "pluginOptions": {},
12 "attributes": {
13 "title": { "type": "string", "required": true },
14 "description": { "type": "text" },
15 "status": {
16 "type": "enumeration",
17 "enum": ["todo", "in_progress", "in_review", "done", "cancelled"],
18 "required": true
19 },
20 "priority": {
21 "type": "enumeration",
22 "enum": ["low", "medium", "high", "critical"],
23 "required": true
24 },
25 "due_date": { "type": "date" },
26 "project": {
27 "type": "relation",
28 "relation": "manyToOne",
29 "target": "api::project.project",
30 "inversedBy": "tasks"
31 },
32 "assignees": {
33 "type": "relation",
34 "relation": "manyToMany",
35 "target": "api::team-member.team-member",
36 "mappedBy": "assigned_tasks"
37 }
38 }
39}Team members link to both projects and tasks. Note the role enumeration, which includes values like admin, manager, developer, designer, and viewer.
1// src/api/team-member/content-types/team-member/schema.json
2{
3 "kind": "collectionType",
4 "collectionName": "team_members",
5 "info": {
6 "singularName": "team-member",
7 "pluralName": "team-members",
8 "displayName": "Team Member"
9 },
10 "options": { "draftAndPublish": false },
11 "pluginOptions": {},
12 "attributes": {
13 "name": { "type": "string", "required": true },
14 "email": { "type": "email", "required": true },
15 "role": {
16 "type": "enumeration",
17 "enum": ["admin", "manager", "developer", "designer", "viewer"],
18 "required": true
19 },
20 "projects": {
21 "type": "relation",
22 "relation": "manyToMany",
23 "target": "api::project.project",
24 "inversedBy": "team_members"
25 },
26 "assigned_tasks": {
27 "type": "relation",
28 "relation": "manyToMany",
29 "target": "api::task.task",
30 "inversedBy": "assignees"
31 }
32 }
33}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.
1// src/api/document/content-types/document/schema.json
2{
3 "kind": "collectionType",
4 "collectionName": "documents",
5 "info": {
6 "singularName": "document",
7 "pluralName": "documents",
8 "displayName": "Document"
9 },
10 "options": { "draftAndPublish": true },
11 "pluginOptions": {},
12 "attributes": {
13 "title": { "type": "string", "required": true },
14 "document_type": {
15 "type": "enumeration",
16 "enum": ["specification", "report", "contract", "invoice", "meeting_notes", "other"],
17 "required": true
18 },
19 "file": {
20 "type": "media",
21 "multiple": false,
22 "allowedTypes": ["files", "images", "videos", "audios"]
23 },
24 "project": {
25 "type": "relation",
26 "relation": "manyToOne",
27 "target": "api::project.project",
28 "inversedBy": "documents"
29 }
30 }
31}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.
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:
Project, Task, Team-member, and Document to grant access. All actions are enabled by default.Repeat for the other three, restricting actions as you go:
find and findOne on every Content Type, plus update on Task. They monitor progress and move tasks along but don't create projects.find and findOne on Task and Document, plus update on Task. To restrict which fields they touch, click the Content Type name to expand field-level permissions and untick anything they shouldn't write.find and findOne on Project and Document only. 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.
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().
1// src/index.ts
2type StatusValue =
3 | 'todo'
4 | 'in_progress'
5 | 'in_review'
6 | 'done'
7 | 'cancelled';
8
9const allowedTransitions: Record<StatusValue, StatusValue[]> = {
10 todo: ['in_progress', 'cancelled'],
11 in_progress: ['in_review', 'cancelled'],
12 in_review: ['in_progress', 'done', 'cancelled'],
13 done: [],
14 cancelled: [],
15};
16
17export default {
18 register({ strapi }: { strapi: any }) {
19 strapi.documents.use(async (context: any, next: any) => {
20 if (context.uid !== 'api::task.task' || context.action !== 'update') {
21 return next();
22 }
23
24 const nextStatus: StatusValue | undefined = context.params?.data?.status;
25 if (!nextStatus) {
26 return next();
27 }
28
29 const existing = await strapi.documents('api::task.task').findOne({
30 documentId: context.params.documentId,
31 fields: ['status'],
32 });
33
34 if (!existing) {
35 return next();
36 }
37
38 const currentStatus = existing.status as StatusValue;
39
40 if (currentStatus === nextStatus) {
41 return next();
42 }
43
44 const permitted = allowedTransitions[currentStatus] ?? [];
45 if (!permitted.includes(nextStatus)) {
46 throw new Error(
47 `Invalid status transition: "${currentStatus}" cannot move to "${nextStatus}".`
48 );
49 }
50
51 return next();
52 });
53 },
54
55 bootstrap() {},
56};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.
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:
1// config/plugins.js
2module.exports = ({ env }) => ({
3 upload: {
4 config: {
5 sizeLimit: 250 * 1024 * 1024, // 250 MB in bytes
6 },
7 },
8});Layer two is the body parser middleware. Configure the body middleware settings in your middlewares array with a matching maxFileSize:
1// config/middlewares.js
2module.exports = [
3 'strapi::logger',
4 'strapi::errors',
5 'strapi::security',
6 'strapi::cors',
7 'strapi::poweredBy',
8 'strapi::query',
9 {
10 name: 'strapi::body',
11 config: {
12 formLimit: '256mb',
13 jsonLimit: '256mb',
14 textLimit: '256mb',
15 formidable: {
16 maxFileSize: 250 * 1024 * 1024,
17 },
18 },
19 },
20 'strapi::session',
21 'strapi::favicon',
22 'strapi::public',
23];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.
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.
Scaffold a new project with the TanStack CLI.
1npx @tanstack/cli create construction-frontend
2cd construction-frontend
3cp .env.example .envSet the Strapi API URL in .env. TanStack Start client components access only PUBLIC_-prefixed variables in client code.
1# .env
2PUBLIC_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.
1// vite.config.ts
2import { defineConfig } from 'vite'
3import { tanstackStart } from '@tanstack/react-start/plugin/vite'
4import viteReact from '@vitejs/plugin-react'
5import tailwindcss from '@tailwindcss/vite'
6
7export default defineConfig({
8 plugins: [
9 tailwindcss(),
10 tanstackStart(),
11 viteReact(),
12 ],
13})Your stylesheet imports Tailwind in a single line, per the Tailwind v4 install guide:
1/* src/styles/app.css */
2@import "tailwindcss";Start the dev server:
1pnpm dev
2# Open http://localhost:3000Strapi 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.
1// src/lib/strapi.ts
2const STRAPI_URL = import.meta.env.PUBLIC_STRAPI_URL as string
3
4export interface StrapiUser {
5 id: number
6 documentId: string
7 username: string
8 email: string
9 confirmed: boolean
10 blocked: boolean
11}
12
13export interface LoginResponse {
14 jwt: string
15 user: StrapiUser
16}
17
18export interface StrapiListResponse<T> {
19 data: T[]
20 meta: {
21 pagination: { page: number; pageSize: number; pageCount: number; total: number }
22 }
23}
24
25export async function login(
26 identifier: string,
27 password: string
28): Promise<LoginResponse> {
29 const res = await fetch(`${STRAPI_URL}/api/auth/local`, {
30 method: 'POST',
31 headers: { 'Content-Type': 'application/json' },
32 body: JSON.stringify({ identifier, password }),
33 })
34
35 if (!res.ok) {
36 const body = await res.json().catch(() => ({}))
37 throw new Error(body?.error?.message ?? 'Login failed')
38 }
39
40 return res.json()
41}
42
43export async function strapiFetch<T>(
44 path: string,
45 token: string | null
46): Promise<T> {
47 const res = await fetch(`${STRAPI_URL}${path}`, {
48 headers: token ? { Authorization: `Bearer ${token}` } : {},
49 })
50
51 if (res.status === 401) {
52 throw new Error('Unauthorized')
53 }
54 if (!res.ok) {
55 throw new Error(`Request failed: ${res.status}`)
56 }
57
58 return res.json()
59}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.
1// src/lib/auth.tsx
2import { createContext, useContext, useState, type ReactNode } from 'react'
3import { login as loginRequest, type StrapiUser } from './strapi'
4
5interface AuthState {
6 user: StrapiUser | null
7 token: string | null
8 isAuthenticated: boolean
9 signIn: (identifier: string, password: string) => Promise<void>
10 signOut: () => void
11}
12
13const AuthContext = createContext<AuthState | null>(null)
14
15export function AuthProvider({ children }: { children: ReactNode }) {
16 const [token, setToken] = useState<string | null>(() =>
17 typeof window !== 'undefined' ? localStorage.getItem('jwt') : null
18 )
19 const [user, setUser] = useState<StrapiUser | null>(() => {
20 if (typeof window === 'undefined') return null
21 const raw = localStorage.getItem('user')
22 return raw ? (JSON.parse(raw) as StrapiUser) : null
23 })
24
25 async function signIn(identifier: string, password: string) {
26 const result = await loginRequest(identifier, password)
27 setToken(result.jwt)
28 setUser(result.user)
29 localStorage.setItem('jwt', result.jwt)
30 localStorage.setItem('user', JSON.stringify(result.user))
31 }
32
33 function signOut() {
34 setToken(null)
35 setUser(null)
36 localStorage.removeItem('jwt')
37 localStorage.removeItem('user')
38 }
39
40 return (
41 <AuthContext.Provider
42 value={{ user, token, isAuthenticated: !!token, signIn, signOut }}
43 >
44 {children}
45 </AuthContext.Provider>
46 )
47}
48
49export function useAuth() {
50 const ctx = useContext(AuthContext)
51 if (!ctx) throw new Error('useAuth must be used within AuthProvider')
52 return ctx
53}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.
1// src/routes/_authenticated.tsx
2import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'
3
4export const Route = createFileRoute('/_authenticated')({
5 beforeLoad: ({ context, location }) => {
6 if (!context.auth.isAuthenticated) {
7 throw redirect({
8 to: '/login',
9 search: { redirect: location.href },
10 })
11 }
12 },
13 component: () => <Outlet />,
14})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.
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.
1// src/lib/types.ts
2export interface Task {
3 id: number
4 documentId: string
5 title: string
6 description?: string
7 status: 'todo' | 'in_progress' | 'in_review' | 'done' | 'cancelled'
8 priority: 'low' | 'medium' | 'high' | 'critical'
9 due_date?: string
10}
11
12export interface Project {
13 id: number
14 documentId: string
15 title: string
16 description?: string
17 status: 'planning' | 'active' | 'on_hold' | 'completed' | 'cancelled'
18 tasks?: Task[]
19}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.
1// src/routes/_authenticated/projects.tsx
2import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'
3import { createFileRoute } from '@tanstack/react-router'
4import { strapiFetch, type StrapiListResponse } from '../../lib/strapi'
5import type { Project } from '../../lib/types'
6
7const projectsQueryOptions = (token: string | null) =>
8 queryOptions({
9 queryKey: ['projects'],
10 queryFn: () =>
11 strapiFetch<StrapiListResponse<Project>>(
12 '/api/projects?populate=tasks',
13 token
14 ),
15 })
16
17export const Route = createFileRoute('/_authenticated/projects')({
18 loader: ({ context }) =>
19 context.queryClient.ensureQueryData(
20 projectsQueryOptions(context.auth.token)
21 ),
22 component: ProjectsPage,
23})
24
25const statusColors: Record<Project['status'], string> = {
26 planning: 'bg-gray-100 text-gray-800',
27 active: 'bg-green-100 text-green-800',
28 on_hold: 'bg-yellow-100 text-yellow-800',
29 completed: 'bg-blue-100 text-blue-800',
30 cancelled: 'bg-red-100 text-red-800',
31}
32
33function ProjectsPage() {
34 const { auth } = Route.useRouteContext()
35 const { data } = useSuspenseQuery(projectsQueryOptions(auth.token))
36
37 return (
38 <div className="p-6">
39 <h1 className="text-2xl font-bold mb-4">Projects</h1>
40 <div className="grid gap-4 md:grid-cols-3">
41 {data.data.map((project) => (
42 <div
43 key={project.documentId}
44 className="rounded-lg border p-4 shadow-sm"
45 >
46 <h2 className="font-semibold">{project.title}</h2>
47 <span
48 className={`mt-2 inline-block rounded px-2 py-1 text-xs ${statusColors[project.status]}`}
49 >
50 {project.status}
51 </span>
52 <p className="mt-2 text-sm text-gray-600">
53 {project.tasks?.length ?? 0} tasks
54 </p>
55 </div>
56 ))}
57 </div>
58 </div>
59 )
60}For a single project's task board, group tasks by status. The route reads projectId from params and populates tasks explicitly.
1// src/routes/_authenticated/projects.$projectId.tsx
2import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'
3import { createFileRoute } from '@tanstack/react-router'
4import { strapiFetch } from '../../lib/strapi'
5import type { Project, Task } from '../../lib/types'
6
7interface SingleProjectResponse {
8 data: Project
9}
10
11const projectQueryOptions = (documentId: string, token: string | null) =>
12 queryOptions({
13 queryKey: ['project', documentId],
14 queryFn: () =>
15 strapiFetch<SingleProjectResponse>(
16 `/api/projects/${documentId}?populate=tasks`,
17 token
18 ),
19 })
20
21export const Route = createFileRoute('/_authenticated/projects/$projectId')({
22 loader: ({ context, params }) =>
23 context.queryClient.ensureQueryData(
24 projectQueryOptions(params.projectId, context.auth.token)
25 ),
26 component: TaskBoard,
27})
28
29const columns: Task['status'][] = ['todo', 'in_progress', 'in_review', 'done', 'cancelled']
30
31function TaskBoard() {
32 const { auth } = Route.useRouteContext()
33 const { projectId } = Route.useParams()
34 const { data } = useSuspenseQuery(
35 projectQueryOptions(projectId, auth.token)
36 )
37
38 const tasks = data.data.tasks ?? []
39
40 return (
41 <div className="p-6">
42 <h1 className="text-2xl font-bold mb-4">{data.data.title}</h1>
43 <div className="grid gap-4 md:grid-cols-4">
44 {columns.map((status) => (
45 <div key={status} className="rounded-lg bg-gray-50 p-3">
46 <h3 className="mb-2 text-sm font-semibold uppercase">{status}</h3>
47 {tasks
48 .filter((task) => task.status === status)
49 .map((task) => (
50 <div
51 key={task.documentId}
52 className="mb-2 rounded border bg-white p-2 text-sm"
53 >
54 <p className="font-medium">{task.title}</p>
55 <span className="text-xs text-gray-500">{task.priority}</span>
56 </div>
57 ))}
58 </div>
59 ))}
60 </div>
61 </div>
62 )
63}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.
1// src/lib/documents.ts
2const STRAPI_URL = import.meta.env.PUBLIC_STRAPI_URL as string
3
4export interface StrapiFile {
5 id: number
6 documentId: string
7 name: string
8 url: string
9}
10
11export interface ProjectDocument {
12 id: number
13 documentId: string
14 title: string
15 document_type: string
16 file?: StrapiFile
17}
18
19interface UploadedFile {
20 id: number
21}
22
23export async function uploadDocument(
24 token: string,
25 projectDocumentId: string,
26 title: string,
27 documentType: string,
28 file: File
29): Promise<void> {
30 const formData = new FormData()
31 formData.append('files', file)
32
33 const uploadRes = await fetch(`${STRAPI_URL}/api/upload`, {
34 method: 'POST',
35 headers: { Authorization: `Bearer ${token}` },
36 body: formData,
37 })
38
39 if (!uploadRes.ok) {
40 throw new Error('File upload failed')
41 }
42
43 const uploaded = (await uploadRes.json()) as UploadedFile[]
44 const fileId = uploaded[0].id
45
46 const createRes = await fetch(`${STRAPI_URL}/api/documents`, {
47 method: 'POST',
48 headers: {
49 Authorization: `Bearer ${token}`,
50 'Content-Type': 'application/json',
51 },
52 body: JSON.stringify({
53 data: {
54 title,
55 document_type: documentType,
56 file: fileId,
57 project: { connect: [projectDocumentId] },
58 },
59 }),
60 })
61
62 if (!createRes.ok) {
63 throw new Error('Document creation failed')
64 }
65}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:
1// src/routes/_authenticated/documents.tsx
2import { useState } from 'react'
3import {
4 queryOptions,
5 useSuspenseQuery,
6 useMutation,
7 useQueryClient,
8} from '@tanstack/react-query'
9import { createFileRoute } from '@tanstack/react-router'
10import { strapiFetch, type StrapiListResponse } from '../../lib/strapi'
11import {
12 uploadDocument,
13 type ProjectDocument,
14} from '../../lib/documents'
15
16const STRAPI_URL = import.meta.env.PUBLIC_STRAPI_URL as string
17
18const documentsQueryOptions = (token: string | null) =>
19 queryOptions({
20 queryKey: ['documents'],
21 queryFn: () =>
22 strapiFetch<StrapiListResponse<ProjectDocument>>(
23 '/api/documents?populate=file',
24 token
25 ),
26 })
27
28export const Route = createFileRoute('/_authenticated/documents')({
29 loader: ({ context }) =>
30 context.queryClient.ensureQueryData(
31 documentsQueryOptions(context.auth.token)
32 ),
33 component: DocumentLibrary,
34})
35
36function DocumentLibrary() {
37 const { auth } = Route.useRouteContext()
38 const queryClient = useQueryClient()
39 const { data } = useSuspenseQuery(documentsQueryOptions(auth.token))
40 const [filter, setFilter] = useState('all')
41
42 const mutation = useMutation({
43 mutationFn: (form: FormData) =>
44 uploadDocument(
45 auth.token as string,
46 form.get('projectId') as string,
47 form.get('title') as string,
48 form.get('documentType') as string,
49 form.get('file') as File
50 ),
51 onSuccess: () => {
52 queryClient.invalidateQueries({ queryKey: ['documents'] })
53 },
54 })
55
56 const docs =
57 filter === 'all'
58 ? data.data
59 : data.data.filter((doc) => doc.document_type === filter)
60
61 return (
62 <div className="p-6">
63 <h1 className="text-2xl font-bold mb-4">Document Library</h1>
64
65 <select
66 className="mb-4 rounded border p-2"
67 value={filter}
68 onChange={(e) => setFilter(e.target.value)}
69 >
70 <option value="all">All types</option>
71 <option value="specification">Specification</option>
72 <option value="report">Report</option>
73 <option value="contract">Contract</option>
74 <option value="invoice">Invoice</option>
75 </select>
76
77 <form
78 className="mb-6 space-y-2"
79 onSubmit={(e) => {
80 e.preventDefault()
81 mutation.mutate(new FormData(e.currentTarget))
82 }}
83 >
84 <input name="title" placeholder="Title" required className="block rounded border p-2" />
85 <input name="projectId" placeholder="Project documentId" required className="block rounded border p-2" />
86 <select name="documentType" className="block rounded border p-2">
87 <option value="specification">Specification</option>
88 <option value="report">Report</option>
89 <option value="contract">Contract</option>
90 </select>
91 <input name="file" type="file" required className="block" />
92 <button
93 type="submit"
94 disabled={mutation.isPending}
95 className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
96 >
97 {mutation.isPending ? 'Uploading...' : 'Upload'}
98 </button>
99 {mutation.isError && (
100 <p className="text-sm text-red-600">{mutation.error.message}</p>
101 )}
102 </form>
103
104 <ul className="space-y-2">
105 {docs.map((doc) => (
106 <li key={doc.documentId} className="flex justify-between rounded border p-3">
107 <span>
108 {doc.title}{' '}
109 <span className="text-xs text-gray-500">({doc.document_type})</span>
110 </span>
111 {doc.file && (
112 <a
113 href={`${STRAPI_URL}${doc.file.url}`}
114 className="text-blue-600 underline"
115 target="_blank"
116 rel="noreferrer"
117 >
118 Download
119 </a>
120 )}
121 </li>
122 ))}
123 </ul>
124 </div>
125 )
126}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.
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:
1curl -X PUT http://localhost:1337/api/tasks/<documentId> \
2 -H "Authorization: Bearer <supervisor-jwt>" \
3 -H "Content-Type: application/json" \
4 -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.
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.
You have a working portal. From here, a few directions make sense:
PUBLIC_STRAPI_URL at the production endpointdue_datein_reviewnpx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.