Hiring pipelines often sprawl across spreadsheets, email threads, and shared task boards. Candidates fall through the cracks, and status updates live in someone's head. By the time you realize a strong applicant was lost, they've accepted another offer.
Building your own Applicant Tracking System (ATS) with Strapi 5 as the backend and Next.js 16 (App Router) as the frontend means you own the data model, you can add custom fields at any time, and you’re not paying per-seat fees when the team grows.
By the end of this tutorial, you will have a working ATS with job postings, candidate applications, and a status-tracking pipeline.
In Brief:
- Model job listings, candidates, and applications as Strapi 5 Collection Types with relational fields.
- Build a public job board and application form with Next.js 16 Server Components.
- Track candidate progress through hiring stages using enumeration fields and filtered API queries.
- Deploy a full-stack ATS you own and can extend.
Prerequisites and Tech Stack
This tutorial pairs Strapi 5 as the headless backend with Next.js 16 as the React-based frontend. Strapi handles content modeling, storage, and API generation; Next.js renders the job board and application forms using Server Components and the App Router.
Before you start, confirm you have the following installed and ready:
- Node.js v20, v22, or v24 (Active or Maintenance LTS only)
- Strapi 5 (latest, via
npx create-strapi@latest) - Next.js 16 (App Router)
- Package manager: npm
- Code editor with TypeScript support (VS Code, Cursor, or equivalent)
- Basic familiarity with React, TypeScript, and REST APIs
No prior Strapi experience is required, though comfort with Content Management Systems (CMS) will help you move faster. If you are new to Strapi's architecture, the quick start guide covers the basics in under 10 minutes.
Set Up the Strapi 5 Backend
The Strapi project scaffolds through the official installation guide. Run the following in your terminal:
npx create-strapi@latest ats-backend
cd ats-backend
npm run developThe CLI walks you through a few prompts: TypeScript (the default), database choice (SQLite works for development), and whether to install dependencies. Accept the defaults to get moving.
Once the dev server starts, open http://localhost:1337/admin in your browser. A registration form appears on the first visit. Complete it to create the administrator account.
Two directories are worth knowing before you start building Content-Types. The src/api/ folder is where Strapi stores the schema definitions, controllers, routes, and services for each Collection Type. The config/ directory holds database, server, middleware, and plugin configuration. You won't need to edit files in either directory manually for this tutorial since the Content-Type Builder handles schema creation through the Admin Panel.
One thing to keep in mind as you work through the rest of this guide: Strapi 5 uses a flat response format. Content fields sit on the data object, not nested under data.attributes like in Strapi 4. Strapi 5 also uses documentId (a string) instead of a numeric id for all API operations. Both changes affect how you consume data in Next.js.
Sign up for the Logbook, Strapi's Monthly newsletter
Design the Content Model for an Applicant Tracking System
An ATS needs Collection Types for job listings, candidates, and applications. The Application type sits between the other two as a joint entity: one candidate can apply to multiple jobs, and one job can receive multiple applications. This structure keeps data normalized and queries flexible. For more on this approach, see the content architecture guide.
Open the Content-Type Builder from the main navigation of the Admin Panel to start creating these types.
Create the Job Listing Collection Type
Click "+ Create new collection type", enter "Job Listing" as the display name, and add the following fields:
| Field Name | Type | Configuration |
|---|---|---|
title | Text | Required |
slug | UID | Attached to title |
department | Enumeration | Values: Engineering, Design, Marketing, Sales, Operations |
location | Text | |
type | Enumeration | Values: Full-time, Part-time, Contract, Remote |
description | Rich Text (Blocks) | |
requirements | Rich Text (Blocks) | |
status | Enumeration | Values: Open, Closed, Draft |
Enable Draft and Publish if you want to use it. The publishedAt field is managed automatically. The slug field generates a URL-friendly string from the title, and in the Content Manager you can update it using the reload icon on the field.
Create the Candidate Collection Type
The Candidate type stores applicant contact details independently from any specific job. This separation means a single candidate record can be linked to multiple applications over time without duplicating personal information. Create a Collection Type named "Candidate" with these fields:
| Field Name | Type | Configuration |
|---|---|---|
firstName | Text | Required |
lastName | Text | Required |
email | Required, Unique | |
phone | Text | |
resumeUrl | Text | Or use a Media field for file uploads |
linkedIn | Text | |
notes | Rich Text (Blocks) |
The Email field type validates format automatically in the Content Manager. The unique constraint on email enforces database-level uniqueness on published candidate records. With Draft and Publish enabled, uniqueness is enforced on published entries only.
Create the Application Collection Type
Create a third Collection Type named "Application" with these fields:
| Field Name | Type | Configuration |
|---|---|---|
stage | Enumeration | Values: Applied, Screening, Interview, Offer, Hired, Rejected |
appliedAt | Date | |
coverLetter | Text (Long text) |
Now add two relation fields:
candidate: Click "Add another field" and select Relation. In the relation configuration UI, select "Candidate" as the target Content-Type, then click the icon for Many-to-one (many Applications belong to one Candidate). Name the fieldcandidateon the Application side.job: Add another Relation field. Select "Job Listing" as the target, configure it as Many-to-one (many Applications belong to one Job Listing). Name the fieldjobon the Application side.
Click Save. Strapi restarts the server to register the new schemas.
Application exists as its own Collection Type (rather than embedded inside Job Listing or Candidate) for flexibility. You can query applications independently, filter by stage, and join candidate and job data through population, all without denormalizing.
Configure API Permissions and Add Seed Data
Set Public API Permissions. By default, unauthenticated requests to protected Strapi endpoints return a 401 error. You need to open the endpoints the headless CMS frontend will consume.
Navigate to Settings → Users and Permissions plugin → Roles → Public. Click the edit button on the Public role. Under Permissions, you will see each Collection Type listed. Enable the following actions:
- Job Listing:
findandfindOne(lets unauthenticated users browse and view postings). - Application:
create(candidates submit applications from the frontend without logging in). - Candidate:
create(the frontend auto-creates candidate records as part of the submission flow).
Keep update, destroy, and find disabled on both Application and Candidate. Applicants should not be able to read other candidates' data or modify existing records. The Strapi community has examples of more granular permission configurations. Click Save when done.
Add Sample Job Listings. Open the Content Manager and create two or three Job Listing entries so the frontend has data to fetch. For example:
- "Senior Frontend Engineer" in Engineering, Remote, Open
- "Product Designer" in Design, Full-time, Open
- "Marketing Manager" in Marketing, Full-time, Draft
Publish the entries you want visible on the public API. Draft entries won't appear in API responses unless you query for them using Strapi's draft system.
Build the Next.js 16 Frontend
This section covers the Next.js project setup, the fetch utility for consuming the Strapi REST API, and the first pages of the ATS.
Scaffold the Next.js Project
The Next.js frontend will consume Strapi's REST API and render the job board, detail pages, and application form. The --app flag scaffolds the project with the App Router directory structure, and --typescript enables TypeScript out of the box. Run the following command to create the frontend application:
npx create-next-app@latest ats-frontend --app --typescriptCreate a .env.local file in the project root to store the Strapi base URL:
STRAPI_URL=http://localhost:1337Variables without the NEXT_PUBLIC_ prefix are server-side only and not accessible in the client bundle. This keeps the API token secure.
With Strapi running as a headless CMS, the Next.js frontend consumes data through REST endpoints. Set up a reusable fetch utility to keep that consumption consistent. Create lib/strapi.ts:
// lib/strapi.ts
import "server-only";
const baseUrl = process.env.STRAPI_URL || "http://localhost:1337";
export async function fetchAPI<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const url = new URL(`/api${path}`, baseUrl);
const response = await fetch(url.toString(), {
headers: {
"Content-Type": "application/json",
...options.headers,
},
...options,
});
if (!response.ok) {
throw new Error(`Strapi API error: ${response.status} ${response.statusText}`);
}
return response.json() as Promise<T>;
}The server-only import prevents this module from being bundled into Client Components. This utility handles base URL construction and error checking so you don't repeat that logic across every page.
Display Job Listings on the Homepage
Create app/jobs/page.tsx. This Server Component fetches all open job listings and renders them as a list:
// app/jobs/page.tsx
import Link from "next/link";
import { fetchAPI } from "@/lib/strapi";
interface JobListing {
id: number;
documentId: string;
title: string;
slug: string;
department: string;
location: string;
type: string;
status: string;
}
interface StrapiResponse {
data: JobListing[];
meta: { pagination: { page: number; pageSize: number; pageCount: number; total: number } };
}
export default async function JobsPage() {
const { data: jobs } = await fetchAPI<StrapiResponse>(
"/job-listings?filters[status][$eq]=Open&sort=createdAt:desc&populate=*",
{ next: { revalidate: 3600 } }
);
return (
<main>
<h1>Open Positions</h1>
<ul>
{jobs.map((job) => (
<li key={job.documentId}>
<Link href={`/jobs/${job.slug}`}>
<h2>{job.title}</h2>
<p>{job.department} · {job.location} · {job.type}</p>
</Link>
</li>
))}
</ul>
</main>
);
}Notice the flat response shape: data[0].title, not data[0].attributes.title. The revalidate: 3600 option tells Next.js to cache this route segment for one hour before revalidating, using stale‑while‑revalidate behavior. Next.js 16 defaults to dynamic rendering for all routes, so without an explicit revalidate or "use cache" directive, every request hits the server.
The filter filters[status][$eq]=Open matches records whose status field equals Open. In Strapi 5, published visibility is handled separately by the status=published parameter (returned by default in REST API responses). You can explore more filter operators in the REST API documentation.
Build the Job Detail Page
Create app/jobs/[slug]/page.tsx for individual job pages. The generateStaticParams function pre-renders known slugs at build time:
// app/jobs/[slug]/page.tsx
import { notFound } from "next/navigation";
import { fetchAPI } from "@/lib/strapi";
import Link from "next/link";
interface JobListing {
documentId: string;
title: string;
slug: string;
department: string;
location: string;
type: string;
status: string;
description: unknown;
requirements: unknown;
}
interface StrapiResponse {
data: JobListing[];
}
interface PageProps {
params: Promise<{ slug: string }>;
}
export async function generateStaticParams() {
const { data } = await fetchAPI<{ data: { slug: string }[] }>(
"/job-listings?fields[0]=slug"
);
return data.map((job) => ({ slug: job.slug }));
}
export default async function JobPage({ params }: PageProps) {
const { slug } = await params;
const { data } = await fetchAPI<StrapiResponse>(
`/job-listings?filters[slug][$eq]=${slug}&populate=*`,
{ next: { revalidate: 3600 } }
);
const job = data[0];
if (!job) notFound();
return (
<article>
<h1>{job.title}</h1>
<p>{job.department} · {job.location} · {job.type}</p>
<section>
<h2>Description</h2>
<pre>{JSON.stringify(job.description, null, 2)}</pre>
</section>
<section>
<h2>Requirements</h2>
<pre>{JSON.stringify(job.requirements, null, 2)}</pre>
</section>
<Link href={`/jobs/${slug}/apply`}>Apply Now</Link>
</article>
);
}The description and requirements fields use Strapi's Rich Text (Blocks) editor, which returns structured JSON. In production, you would parse this JSON with a block renderer component rather than displaying raw output.
Handle Job Applications with a Submission Form
The application flow creates two records in sequence: a Candidate and then an Application that links the candidate to a specific job. This section covers the Client Component form and the API submission logic.
Create the Application Form Component
Since forms require browser interactivity, this needs to be a Client Component. Create app/jobs/[slug]/apply/ApplicationForm.tsx:
"use client";
import { useState, FormEvent } from "react";
const STRAPI_URL =
process.env.NEXT_PUBLIC_STRAPI_URL ?? "http://localhost:1337";
export default function ApplicationForm({
jobDocumentId,
}: {
jobDocumentId: string;
}) {
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [coverLetter, setCoverLetter] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setSuccess(false);
setIsLoading(true);
try {
// Step 1: Create the candidate record
const candidateRes = await fetch(`${STRAPI_URL}/api/candidates`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
data: { firstName, lastName, email },
}),
});
if (!candidateRes.ok) {
const errData = await candidateRes.json();
throw new Error(
errData?.error?.message ?? "Failed to create candidate."
);
}
const candidateResult = await candidateRes.json();
const candidateDocumentId = candidateResult.data.documentId;
// Step 2: Create the application, linking candidate and job by documentId
const applicationRes = await fetch(`${STRAPI_URL}/api/applications`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
data: {
stage: "Applied",
coverLetter,
candidate: candidateDocumentId,
job: jobDocumentId,
},
}),
});
if (!applicationRes.ok) {
const errData = await applicationRes.json();
throw new Error(
errData?.error?.message ?? "Failed to submit application."
);
}
setSuccess(true);
setFirstName("");
setLastName("");
setEmail("");
setCoverLetter("");
} catch (err) {
setError(
err instanceof Error ? err.message : "An unexpected error occurred."
);
} finally {
setIsLoading(false);
}
}
return (
<div>
{success && (
<div role="alert" style={{ color: "green" }}>
Application submitted.
</div>
)}
{error && (
<div role="alert" style={{ color: "red" }}>
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="firstName">First Name</label>
<input
id="firstName" type="text" value={firstName}
onChange={(e) => setFirstName(e.target.value)} required
/>
</div>
<div>
<label htmlFor="lastName">Last Name</label>
<input
id="lastName" type="text" value={lastName}
onChange={(e) => setLastName(e.target.value)} required
/>
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email" type="email" value={email}
onChange={(e) => setEmail(e.target.value)} required
/>
</div>
<div>
<label htmlFor="coverLetter">Cover Letter</label>
<textarea
id="coverLetter" value={coverLetter}
onChange={(e) => setCoverLetter(e.target.value)} required
/>
</div>
<button type="submit" disabled={isLoading}>
{isLoading ? "Submitting..." : "Apply Now"}
</button>
</form>
</div>
);
}Submit Applications to the Strapi API
The form follows a two-step creation pattern. First, it POSTs to /api/candidates to create the candidate record and captures the returned documentId. Then it POSTs to /api/applications, linking the candidate and job via their documentId strings.
Both POST bodies use the { "data": { ... } } wrapper that Strapi 5 requires. Single relation fields (like candidate and job) accept a documentId string directly, not a numeric ID. This is one of the most common mistakes when migrating from Strapi 4 patterns.
To wire this form into the job detail page, create app/jobs/[slug]/apply/page.tsx:
// app/jobs/[slug]/apply/page.tsx
import { notFound } from "next/navigation";
import { fetchAPI } from "@/lib/strapi";
import ApplicationForm from "./ApplicationForm";
interface PageProps {
params: Promise<{ slug: string }>;
}
export default async function ApplyPage({ params }: PageProps) {
const { slug } = await params;
const { data } = await fetchAPI<{ data: { documentId: string; title: string }[] }>(
`/job-listings?filters[slug][$eq]=${slug}&fields[0]=documentId&fields[1]=title`
);
const job = data[0];
if (!job) notFound();
return (
<main>
<h1>Apply for {job.title}</h1>
<ApplicationForm jobDocumentId={job.documentId} />
</main>
);
}The Server Component fetches the job's documentId and passes it down to the Client Component as a prop. The form never needs to know the slug or numeric ID. You can read more about the Next.js integration pattern and browse other Strapi integrations on the Strapi website.
Track Application Status Through Hiring Stages
The Application Collection Type's stage enumeration field models a hiring pipeline. Strapi's filter syntax lets you query applications at any stage without writing custom endpoints.
Filter Applications by Stage
To fetch all applications at a specific stage with their related candidate and job data, construct a filtered REST API query:
GET /api/applications?filters[stage][$eq]=Interview&populate=candidate,jobBuild a dashboard page at app/dashboard/page.tsx that groups applications by stage:
// app/dashboard/page.tsx
import { fetchAPI } from "@/lib/strapi";
const STAGES = ["Applied", "Screening", "Interview", "Offer", "Hired", "Rejected"];
interface Application {
documentId: string;
stage: string;
appliedAt: string;
candidate: { firstName: string; lastName: string; email: string };
job: { title: string };
}
interface StrapiResponse {
data: Application[];
}
export default async function DashboardPage() {
const { data: applications } = await fetchAPI<StrapiResponse>(
"/applications?populate=candidate,job&sort=createdAt:desc",
{ cache: "no-store" }
);
return (
<main>
<h1>Hiring Dashboard</h1>
{STAGES.map((stage) => {
const stageApps = applications.filter((app) => app.stage === stage);
return (
<section key={stage}>
<h2>{stage} ({stageApps.length})</h2>
<ul>
{stageApps.map((app) => (
<li key={app.documentId}>
{app.candidate.firstName} {app.candidate.lastName} for{" "}
{app.job.title}
</li>
))}
</ul>
</section>
);
})}
</main>
);
}The populate=candidate,job parameter specifies which relations to include. Using populate=* would work too, but explicit population paths give you control over payload size. The cache: "no-store" option ensures the dashboard always shows the latest data.
For this dashboard to work, you need to enable find and findOne on the Application and Candidate Collection Types for the appropriate role. In a production system, protect this behind authentication rather than using the Public role. The Users and Permissions plugin supports role-based access, and API tokens handle server-to-server requests.
Update Application Stage
Moving a candidate through the pipeline means updating the stage field on their Application entry. Strapi 5 uses PUT (not PATCH) for updates, with the documentId in the URL. The following function demonstrates the update pattern:
async function updateApplicationStage(
documentId: string,
stage: string
) {
const response = await fetch(
`${process.env.STRAPI_URL}/api/applications/${documentId}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
},
body: JSON.stringify({
data: { stage },
}),
}
);
if (!response.ok) {
const errData = await response.json();
throw new Error(errData?.error?.message ?? "Failed to update application.");
}
return response.json();
}Only the fields included in the data object get updated; everything else stays unchanged. The Authorization header with an API token protects this write operation. Configure API tokens under Settings → Global settings → API Tokens in the Admin Panel.
For a production system, call this function from a Server Action or an API route handler, not from the client. This keeps the API token on the server side.
Strapi for enterprise teams can also review features like audit logs and SSO for more controls around a production ATS deployment.
How Strapi Powers This
This tutorial built a full-stack applicant tracking system: job postings, candidate applications, relational data modeling, and stage-based pipeline tracking. Strapi 5 enabled this approach through:
- The Content-Type Builder modeled jobs, candidates, and applications without writing a schema file.
- Relational fields linked applications to both candidates and jobs, keeping data normalized and queryable.
- Strapi 5's flat response format and
documentIdstrings simplified frontend data access across every Next.js page. - REST API filters handled stage-based pipeline queries with intuitive parameter syntax.
- The Users and Permissions plugin controlled public and authenticated access at the endpoint level.
To start building with Strapi 5, run npx create-strapi@latest locally or deploy to Strapi Cloud to get a hosted instance running in minutes.
Where to Go from Here
You now have a functional ATS with job listings, candidate applications, and stage tracking. Here are concrete next steps to extend it:
- Add authentication to the dashboard. Use the Strapi Users and Permissions plugin or an external auth solution to restrict access to the hiring dashboard.
- Send email notifications when a candidate's stage changes. Strapi lifecycle hooks or Document Service middleware can trigger emails on
afterUpdateevents. - Support resume uploads. Swap the
resumeUrltext field for a Media field and use the Strapi Media Library. The frontend sends aFormDatarequest to the Upload endpoint. - Deploy for real. Push the Strapi backend to Strapi Cloud and the Next.js frontend to Vercel. Both platforms support environment variable configuration for connecting the two services.
For deeper reference on everything covered here, the Strapi documentation is the best starting point. The Strapi blog covers more advanced integration guides and patterns.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.