Pet care businesses run on data that lives in three different worlds: the public adoption board anyone can browse, the private medical records only staff should touch, and the grooming calendar that breaks the moment two appointments collide. If you stitch these together with separate tools, you have to account for sync problems and double-bookings.
This tutorial shows you how to build a pet care management platform with Strapi and Next.js where a single backend powers all three surfaces. You model the data once in Strapi 5, expose published adoption listings to the public, lock vet records behind authentication, and write a custom service that refuses overlapping grooming slots before they ever hit the database.
In brief:
- Model five interconnected Collection Types in Strapi 5 with deep relations (one pet to many vet records to many vaccinations)
- Use Draft and Publish to control which pet profiles appear on the public adoption board
- Split public SEO pages and authenticated dashboards cleanly with Next.js 16 route groups
- Prevent double-booking with a custom Strapi service that queries the Document Service API for time-slot overlaps
What We're Building
You'll build a three-module pet care platform. Strapi 5 manages pets, vet records, vaccinations, grooming appointments, and adoption applications. The adoption board is public-facing with server-side rendering (SSR) for search engine optimization (SEO), so a published pet profile shows up with proper Open Graph tags when someone shares the link. Vet records and grooming scheduling sit behind authentication, where staff add visits, track vaccinations, and book grooming slots.
Next.js 16 route groups keep the two surfaces separate without polluting your URLs. The (public) group serves /adopt/[petId] pages that anyone can read, while the (dashboard) group serves /records/[petId] pages that a proxy file protects. Both groups talk to the same Strapi backend through its REST API.
What you'll learn:
- Modeling five interconnected Content-Types with bidirectional relations
- Configuring Draft and Publish so staff draft pet profiles and publish them when a pet is ready for adoption
- Building public SEO-optimized pages alongside authenticated dashboards
- Preventing double-bookings with a custom Strapi service
- Handling pet photo uploads through the Media Library
Prerequisites
Before starting, make sure you have the following installed and pinned:
- Node.js v22 LTS (Strapi 5 supports Active LTS releases; v18 is end-of-life)
- Strapi 5.x (this tutorial uses 5.48.0, the latest stable at time of writing)
- Next.js 16.2.x (App Router, Server Components, Server Actions)
- React 19.2.x (Next.js 16 includes React 19.2)
- PostgreSQL 17.x (14.0+ supported) for the relational data
- date-fns 4.x for date formatting
- Basic familiarity with TypeScript, React, and REST APIs
- A code editor and terminal
Setting Up the Strapi Backend
Strapi 5 gives you a Content-Type Builder, a Media Library, and the Document Service API out of the box. You define your data model, enable Draft and Publish, and write one custom service for booking logic.
Step 1: Install Strapi 5
Create the project with the canonical install command:
npx create-strapi@latestThe CLI walks you through setup interactively. When prompted, name the project pet-care-backend, select PostgreSQL as your database, and accept the TypeScript default. The CLI collects your database connection details during this step.
Once the project is created, verify your .env file in the project root contains the correct database credentials:
# .env
DATABASE_CLIENT=postgres
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=petcare
DATABASE_USERNAME=your_username
DATABASE_PASSWORD=your_passwordStart the development server:
cd pet-care-backend && npm run developThis opens the Admin Panel at http://localhost:1337/admin. Create your first administrator account. Strapi watches for changes automatically in development. The old strapi watch-admin command was removed in Strapi 5, and watch mode is now the default behavior of npm run develop.
Step 2: Define the Pet and Vet Record Content-Types
You can build Content-Types in the Content-Type Builder UI, but editing schema.json directly is faster and shows exactly what's happening. Schemas live at ./src/api/[api-name]/content-types/[content-type-name]/schema.json. For a deeper look at how to approach content modeling, that guide covers the principles.
Start with the Pet Collection Type. It holds the basics plus a media field for the photo and an enumeration for status.
// src/api/pet/content-types/pet/schema.json
{
"kind": "collectionType",
"collectionName": "pets",
"info": {
"singularName": "pet",
"pluralName": "pets",
"displayName": "Pet"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"name": { "type": "string", "required": true },
"species": { "type": "string" },
"breed": { "type": "string" },
"age": { "type": "integer" },
"bio": { "type": "text" },
"photo": {
"type": "media",
"multiple": false
},
"status": {
"type": "enumeration",
"enum": ["available", "adopted", "in care"],
"default": "available"
},
"vetRecords": {
"type": "relation",
"relation": "oneToMany",
"target": "api::vet-record.vet-record",
"mappedBy": "pet"
}
}
}The vetRecords relation is the inversed side of a one-to-many, so it uses mappedBy. Now define the Vet Record Collection Type. It points back to the pet using manyToOne (the owning side, using inversedBy) and forward to vaccinations using oneToMany (with mappedBy). If you are new to how relations work, that distinction is the part worth slowing down on.
// src/api/vet-record/content-types/vet-record/schema.json
{
"kind": "collectionType",
"collectionName": "vet_records",
"info": {
"singularName": "vet-record",
"pluralName": "vet-records",
"displayName": "Vet Record"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"date": { "type": "date", "required": true },
"diagnosis": { "type": "text" },
"treatment": { "type": "text" },
"veterinarianName": { "type": "string" },
"pet": {
"type": "relation",
"relation": "manyToOne",
"target": "api::pet.pet",
"inversedBy": "vetRecords"
},
"vaccinations": {
"type": "relation",
"relation": "oneToMany",
"target": "api::vaccination.vaccination",
"mappedBy": "vetRecord"
}
}
}One-to-many relationships in Strapi are always bidirectional in behavior, but do not always require explicit pairing with a matching many-to-one attribute. The pattern of pairing oneToMany with manyToOne is recommended in the Strapi models documentation, but not strictly required.
Step 3: Configure Vaccination and Appointment Relations
Add the three remaining Collection Types. The Vaccination type closes the chain from vet record.
// src/api/vaccination/content-types/vaccination/schema.json
{
"kind": "collectionType",
"collectionName": "vaccinations",
"info": {
"singularName": "vaccination",
"pluralName": "vaccinations",
"displayName": "Vaccination"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"vaccineName": { "type": "string", "required": true },
"dateAdministered": { "type": "date" },
"nextDueDate": { "type": "date" },
"vetRecord": {
"type": "relation",
"relation": "manyToOne",
"target": "api::vet-record.vet-record",
"inversedBy": "vaccinations"
}
}
}The Grooming Appointment type stores start and end times as datetime fields, which the booking service compares for overlaps.
// src/api/grooming-appointment/content-types/grooming-appointment/schema.json
{
"kind": "collectionType",
"collectionName": "grooming_appointments",
"info": {
"singularName": "grooming-appointment",
"pluralName": "grooming-appointments",
"displayName": "Grooming Appointment"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"startTime": { "type": "datetime", "required": true },
"endTime": { "type": "datetime", "required": true },
"serviceType": {
"type": "enumeration",
"enum": ["bath", "full-groom", "nail-trim", "deshedding"],
"default": "bath"
},
"notes": { "type": "text" },
"status": {
"type": "enumeration",
"enum": ["scheduled", "completed", "cancelled"],
"default": "scheduled"
},
"pet": {
"type": "relation",
"relation": "manyToOne",
"target": "api::pet.pet"
}
}
}Finally, the Adoption Application type captures public inquiries.
// src/api/adoption-application/content-types/adoption-application/schema.json
{
"kind": "collectionType",
"collectionName": "adoption_applications",
"info": {
"singularName": "adoption-application",
"pluralName": "adoption-applications",
"displayName": "Adoption Application"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"applicantName": { "type": "string", "required": true },
"contact": { "type": "string", "required": true },
"message": { "type": "text" },
"submittedDate": { "type": "datetime" },
"status": {
"type": "enumeration",
"enum": ["new", "reviewing", "approved", "rejected"],
"default": "new"
},
"pet": {
"type": "relation",
"relation": "manyToOne",
"target": "api::pet.pet"
}
}
}Restart npm run develop if it doesn't pick up the new schemas automatically. You'll see all five Collection Types in the Admin Panel.
Step 4: Enable Draft and Publish on Pets
You already set "draftAndPublish": true on the Pet schema. Draft and Publish is available but disabled by default, so you opt in per Content-Type. You can also toggle it through the "Draft and Publish" checkbox in the Content-Type Builder's Advanced Settings.
The behavior that matters for the adoption board: the REST API returns published versions by default. A request with no status parameter is equivalent to status=published, and the publishedAt field is null for drafts. This is what keeps draft pet profiles staff-only: the public adoption board hits the REST API without a status parameter and never sees them.
# Public board sees only published pets (default behavior)
GET /api/pets
# Staff can request drafts explicitly
GET /api/pets?status=draftIf you're coming from Strapi 4, note that the publicationState parameter with its live and preview values was removed in Strapi 5. The replacement is the status parameter with published and draft values. The Draft and Publish guide covers what changed in detail.
Step 5: Build the Double-Booking Prevention Service
This is the core booking logic. You write a custom service that queries the Document Service API for any existing appointment that overlaps a proposed time slot. Two slots overlap when the existing start is before the proposed end and the existing end is after the proposed start.
Create the service file.
// src/api/grooming-appointment/services/grooming-appointment.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreService(
'api::grooming-appointment.grooming-appointment',
({ strapi }) => ({
/**
* Returns true when the proposed [startTime, endTime] slot is free.
* Overlap: existing.startTime < proposed.endTime AND existing.endTime > proposed.startTime
*/
async checkAvailability(
startTime: string,
endTime: string,
excludeDocumentId?: string
): Promise<boolean> {
const filters: Record<string, unknown> = {
$and: [
{ startTime: { $lt: endTime } },
{ endTime: { $gt: startTime } },
],
};
if (excludeDocumentId) {
(filters.$and as unknown[]).push({
documentId: { $ne: excludeDocumentId },
});
}
const overlapping = await strapi
.documents('api::grooming-appointment.grooming-appointment')
.findMany({
filters,
status: 'published',
});
return overlapping.length === 0;
},
})
);The $lt and $gt operators on datetime fields handle the overlap math. The optional excludeDocumentId lets you reuse this when editing an existing appointment, so a booking doesn't conflict with itself. Note the use of documentId rather than a numeric id, which is the Strapi 5 identifier for all content API references.
One critical detail: findMany defaults to returning draft entries, not published ones. You must pass status: 'published' explicitly if you want published records. Without it, the service might return no results (or wrong results) in production when you expect published-only records.
Now wire the service into the appointment controller. Override create so a booking request that overlaps returns a 409 Conflict instead of writing a colliding row.
// src/api/grooming-appointment/controllers/grooming-appointment.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreController(
'api::grooming-appointment.grooming-appointment',
({ strapi }) => ({
async create(ctx) {
const { startTime, endTime } = ctx.request.body.data;
const isAvailable = await strapi
.service('api::grooming-appointment.grooming-appointment')
.checkAvailability(startTime, endTime);
if (!isAvailable) {
return ctx.conflict('The requested time slot is already booked.');
}
return super.create(ctx);
},
})
);The controller calls the service through strapi.service('api::grooming-appointment.grooming-appointment'), which resolves to the core service you extended with checkAvailability. In Strapi 5, core service methods use the Document Service API under the hood rather than the deprecated Entity Service API.
Before testing, open the Admin Panel and set permissions under Settings, Users and Permissions, Roles. Give the Public role find and findOne access to Pet, and create access to Adoption Application. Give the Authenticated role broader access to vet records, vaccinations, and grooming appointments.
Building the Next.js 16 Frontend
The frontend reads from Strapi's flat REST API and renders two distinct surfaces. The adoption board is public and server-rendered for SEO; the dashboard is private and protected by a proxy file.
Step 1: Set Up the Next.js 16 Project
Create the app and add Tailwind CSS v4 and date-fns, following the official Next.js guide. For a broader introduction to using Next.js with Strapi, the beginner's guide covers the fundamentals.
npx create-next-app@latest pet-care-frontend
cd pet-care-frontend
npm install date-fns
npm install tailwindcss @tailwindcss/postcss postcssTailwind v4 uses CSS-first configuration with @theme for custom values. Create the PostCSS config:
// postcss.config.mjs
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;Then import Tailwind in your global stylesheet:
/* app/globals.css */
@import "tailwindcss";
@theme {
--color-primary: #3b82f6;
}Set environment variables for the Strapi connection. Use the public URL in client-visible code; keep the API token for server-side requests.
# .env.local
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your-server-only-tokenAdd the following image configuration to next.config.js for your Strapi instance.
// next.config.js
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'http',
hostname: 'localhost',
port: '1337',
pathname: '/**',
},
],
},
};
module.exports = nextConfig;Restart your dev server after adding this config. This lets next/image load Strapi photos.
Now create the route groups. Both groups disappear from the URL, so (public)/adopt/[petId] resolves to /adopt/[petId] and (dashboard)/records/[petId] resolves to /records/[petId].
app/
├── (public)/
│ └── adopt/
│ ├── page.tsx
│ └── [petId]/
│ └── page.tsx
├── (dashboard)/
│ └── records/
│ └── [petId]/
│ └── page.tsx
├── actions.ts
└── globals.cssAdd shared TypeScript types matching the flat Strapi 5 response. Notice there's no data.attributes wrapper, and documentId is the identifier you query against.
// app/lib/types.ts
export interface StrapiMedia {
id: number;
documentId: string;
name: string;
url: string;
}
export interface Pet {
id: number;
documentId: string;
name: string;
species: string;
breed: string;
age: number;
bio: string;
status: 'available' | 'adopted' | 'in care';
photo?: StrapiMedia;
}
export interface StrapiResponse<T> {
data: T;
meta: Record<string, unknown>;
}Step 2: Build the Public Adoption Board
The board is a Server Component that fetches available pets. Because the REST API returns published entries by default, drafts stay hidden automatically. Populate is explicit in Strapi 5, so request only the photo fields you need. For details on how populate and filtering work, that guide covers the patterns.
// app/(public)/adopt/page.tsx
import Image from 'next/image';
import Link from 'next/link';
import type { Pet, StrapiResponse } from '@/app/lib/types';
async function getAvailablePets(): Promise<Pet[]> {
const url = `${process.env.NEXT_PUBLIC_STRAPI_URL}/api/pets?filters[status][$eq]=available&populate[photo][fields]=name&populate[photo][fields]=url`;
const res = await fetch(url, { next: { tags: ['pets'] } });
if (!res.ok) {
throw new Error('Failed to load adoption listings');
}
const json: StrapiResponse<Pet[]> = await res.json();
return json.data;
}
export default async function AdoptionBoard() {
const pets = await getAvailablePets();
return (
<main className="mx-auto max-w-5xl p-6">
<h1 className="mb-6 text-3xl font-bold">Pets Available for Adoption</h1>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{pets.map((pet) => (
<Link
key={pet.documentId}
href={`/adopt/${pet.documentId}`}
className="rounded-lg border p-4 hover:shadow-md"
>
{pet.photo && (
<Image
src={`${process.env.NEXT_PUBLIC_STRAPI_URL}${pet.photo.url}`}
alt={pet.name}
width={400}
height={300}
className="rounded-md object-cover"
/>
)}
<h2 className="mt-3 text-xl font-semibold">{pet.name}</h2>
<p className="text-gray-600">
{pet.breed} · {pet.age} years old
</p>
</Link>
))}
</div>
</main>
);
}The detail page fetches a single pet by documentId and generates dynamic SEO metadata. In Next.js 16, params is a Promise you must await, both in the page and in generateMetadata.
// app/(public)/adopt/[petId]/page.tsx
import Image from 'next/image';
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { AdoptionForm } from '@/app/ui/adoption-form';
import type { Pet, StrapiResponse } from '@/app/lib/types';
type Props = {
params: Promise<{ petId: string }>;
};
async function getPet(documentId: string): Promise<Pet | null> {
const url = `${process.env.NEXT_PUBLIC_STRAPI_URL}/api/pets/${documentId}?populate=photo`;
const res = await fetch(url, { next: { tags: ['pets'] } });
if (!res.ok) return null;
const json: StrapiResponse<Pet> = await res.json();
return json.data;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { petId } = await params;
const pet = await getPet(petId);
if (!pet) {
return { title: 'Pet not found' };
}
const photoUrl = pet.photo
? `${process.env.NEXT_PUBLIC_STRAPI_URL}${pet.photo.url}`
: undefined;
return {
title: `${pet.name} - Available for Adoption`,
description: pet.bio,
openGraph: {
title: `${pet.name} - Available for Adoption`,
description: pet.bio,
images: photoUrl ? [photoUrl] : [],
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: `${pet.name} - Available for Adoption`,
description: pet.bio,
},
};
}
export default async function PetDetailPage({ params }: Props) {
const { petId } = await params;
const pet = await getPet(petId);
if (!pet) notFound();
return (
<main className="mx-auto max-w-3xl p-6">
{pet.photo && (
<Image
src={`${process.env.NEXT_PUBLIC_STRAPI_URL}${pet.photo.url}`}
alt={pet.name}
width={800}
height={600}
className="rounded-lg object-cover"
/>
)}
<h1 className="mt-4 text-3xl font-bold">{pet.name}</h1>
<p className="text-gray-600">
{pet.breed} · {pet.age} years old
</p>
<p className="mt-4">{pet.bio}</p>
<h2 className="mt-8 text-2xl font-semibold">Inquire About {pet.name}</h2>
<AdoptionForm petDocumentId={pet.documentId} />
</main>
);
}Next.js automatically detects HTML-limited bots (e.g., facebookexternalhit) by User Agent and blocks page rendering until metadata is resolved, ensuring the Open Graph tags appear in <head> when a listing is shared.
Step 3: Handle Adoption Inquiries with Server Actions
A Server Action posts the form to Strapi's Adoption Application endpoint. Because the action runs server-side, no API token reaches the browser. The Strapi 5 REST API expects the payload wrapped in a data object, with relations referenced by documentId.
// app/actions.ts
'use server'
import { revalidateTag } from 'next/cache';
type FormState = { message: string };
export async function submitAdoptionApplication(
petDocumentId: string,
prevState: FormState,
formData: FormData
): Promise<FormState> {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/adoption-applications`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
data: {
applicantName: formData.get('applicantName'),
contact: formData.get('contact'),
message: formData.get('message'),
submittedDate: new Date().toISOString(),
pet: { connect: [petDocumentId] },
},
}),
}
);
if (!res.ok) {
return { message: 'Failed to submit application. Please try again.' };
}
revalidateTag('pets', 'max');
return { message: 'Application submitted! The shelter will reach out soon.' };
} catch {
return { message: 'Failed to submit application. Please try again.' };
}
}The pet: { connect: ['petDocumentId'] } shape uses the connect syntax for linking relations. The revalidateTag function takes a cacheLife profile as its second argument ('max' in this case), which is required in Next.js 16. The single-argument form revalidateTag('pets') is deprecated.
The form itself is a Client Component using useActionState to surface pending state and the server response.
// app/ui/adoption-form.tsx
'use client'
import { useActionState } from 'react';
import { submitAdoptionApplication } from '@/app/actions';
const initialState = { message: '' };
export function AdoptionForm({ petDocumentId }: { petDocumentId: string }) {
const action = submitAdoptionApplication.bind(null, petDocumentId);
const [state, formAction, pending] = useActionState(action, initialState);
return (
<form action={formAction} className="mt-4 space-y-3">
<input
type="text"
name="applicantName"
placeholder="Your name"
required
className="w-full rounded border p-2"
/>
<input
type="text"
name="contact"
placeholder="Email or phone"
required
className="w-full rounded border p-2"
/>
<textarea
name="message"
placeholder="Tell us why you're a good match"
className="w-full rounded border p-2"
/>
<p aria-live="polite" className="text-sm">{state.message}</p>
<button
disabled={pending}
className="rounded bg-primary px-4 py-2 text-white disabled:opacity-50"
>
{pending ? 'Submitting...' : 'Submit Application'}
</button>
</form>
);
}Step 4: Build the Authenticated Management Dashboard
Staff log in with Strapi's Users and Permissions plugin. Authentication uses the POST /api/auth/local endpoint, which returns a JSON Web Token (JWT). You store that token in a cookie and check it in a proxy file. For more on JWT authentication with Strapi and Next.js, that guide covers the full flow.
In Next.js 16, the middleware file is renamed to proxy.ts, and the exported function is named proxy. This is a breaking change from Next.js 15, where the file was middleware.ts and the function was middleware.
// proxy.ts
import { NextRequest, NextResponse } from 'next/server';
export default async function proxy(req: NextRequest) {
const token = req.cookies.get('session')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', req.nextUrl));
}
return NextResponse.next();
}
export const config = {
matcher: '/records/:path*',
};The matcher scopes the proxy to the /records paths so the public adoption board stays open. Server Actions are not excluded from the proxy chain, but the official docs emphasize: always verify authentication and authorization inside each Server Action rather than relying on the proxy alone.
Build a login action that calls Strapi and stores the JWT.
// app/(dashboard)/login/actions.ts
'use server'
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
export async function login(prevState: { error: string }, formData: FormData) {
const res = await fetch(`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/auth/local`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identifier: formData.get('identifier'),
password: formData.get('password'),
}),
});
if (!res.ok) {
return { error: 'Invalid credentials' };
}
const { jwt } = await res.json();
const cookieStore = await cookies();
cookieStore.set('session', jwt, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
});
redirect('/records');
}The records page reads the JWT from cookies and fetches a pet's vet records and vaccinations with explicit, nested populate. Authenticated requests pass the token as a Bearer header.
// app/(dashboard)/records/[petId]/page.tsx
import { cookies } from 'next/headers';
import { format } from 'date-fns';
type Props = {
params: Promise<{ petId: string }>;
};
interface Vaccination {
documentId: string;
vaccineName: string;
dateAdministered: string;
nextDueDate: string;
}
interface VetRecord {
documentId: string;
date: string;
diagnosis: string;
treatment: string;
veterinarianName: string;
vaccinations: Vaccination[];
}
interface PetWithRecords {
documentId: string;
name: string;
vetRecords: VetRecord[];
}
async function getPetRecords(
documentId: string,
token: string
): Promise<PetWithRecords | null> {
const url = `${process.env.NEXT_PUBLIC_STRAPI_URL}/api/pets/${documentId}?populate[vetRecords][populate][vaccinations]=true`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
});
if (!res.ok) return null;
const json = await res.json();
return json.data;
}
export default async function RecordsPage({ params }: Props) {
const { petId } = await params;
const cookieStore = await cookies();
const token = cookieStore.get('session')?.value;
if (!token) {
return <p className="p-6">You must be signed in to view records.</p>;
}
const pet = await getPetRecords(petId, token);
if (!pet) {
return <p className="p-6">Records not found.</p>;
}
return (
<main className="mx-auto max-w-3xl p-6">
<h1 className="text-3xl font-bold">{pet.name} - Medical History</h1>
{pet.vetRecords.map((record) => (
<section key={record.documentId} className="mt-6 rounded border p-4">
<h2 className="text-xl font-semibold">
{format(new Date(record.date), 'PPP')}
</h2>
<p>Veterinarian: {record.veterinarianName}</p>
<p>Diagnosis: {record.diagnosis}</p>
<p>Treatment: {record.treatment}</p>
{record.vaccinations.length > 0 && (
<ul className="mt-3 list-disc pl-5">
{record.vaccinations.map((vax) => (
<li key={vax.documentId}>
{vax.vaccineName} - next due{' '}
{format(new Date(vax.nextDueDate), 'PPP')}
</li>
))}
</ul>
)}
</section>
))}
</main>
);
}Scheduling a grooming appointment from the dashboard posts to the same endpoint the controller guards. If the slot overlaps, Strapi returns a 409 and your Server Action surfaces the conflict.
// app/(dashboard)/records/actions.ts
'use server'
import { cookies } from 'next/headers';
export async function scheduleGrooming(
prevState: { message: string },
formData: FormData
): Promise<{ message: string }> {
const cookieStore = await cookies();
const token = cookieStore.get('session')?.value;
const res = await fetch(
`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/grooming-appointments`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
data: {
startTime: formData.get('startTime'),
endTime: formData.get('endTime'),
serviceType: formData.get('serviceType'),
pet: { connect: [formData.get('petDocumentId')] },
},
}),
}
);
if (res.status === 409) {
return { message: 'That time slot is already booked. Pick another.' };
}
if (!res.ok) {
return { message: 'Could not schedule the appointment.' };
}
return { message: 'Grooming appointment scheduled.' };
}The scheduling form is a Client Component that mirrors the adoption form pattern. It uses useActionState to track pending state and surface either a success message or the 409 conflict text the server action returns. Staff pick a start and end time, choose a service type, and submit against the guarded endpoint. The form passes petDocumentId through a hidden input rather than binding it, since scheduleGrooming reads the pet reference straight from formData.
// app/ui/grooming-form.tsx
'use client'
import { useActionState } from 'react';
import { scheduleGrooming } from '@/app/(dashboard)/records/actions';
export function GroomingForm({ petDocumentId }: { petDocumentId: string }) {
const [state, formAction, pending] = useActionState(scheduleGrooming, {
message: '',
});
return (
<form action={formAction} className="mt-4 space-y-3">
<input
type="datetime-local"
name="startTime"
required
className="w-full rounded border p-2"
/>
<input
type="datetime-local"
name="endTime"
required
className="w-full rounded border p-2"
/>
<select name="serviceType" className="w-full rounded border p-2">
<option value="bath">bath</option>
<option value="full-groom">full-groom</option>
<option value="nail-trim">nail-trim</option>
<option value="deshedding">deshedding</option>
</select>
<input type="hidden" name="petDocumentId" value={petDocumentId} />
<p aria-live="polite" className="text-sm">{state.message}</p>
<button
disabled={pending}
className="rounded bg-primary px-4 py-2 text-white disabled:opacity-50"
>
{pending ? 'Scheduling...' : 'Schedule Appointment'}
</button>
</form>
);
}Reviewing adoption applications can be handled in the Strapi Admin Panel, where staff manage application content; any submission statuses depend on the application's specific content model. If you want this inside the dashboard, fetch /api/adoption-applications?populate=pet with the Bearer token and render the same status enum as a dropdown wired to a PUT Server Action. That keeps the review workflow in the same authenticated surface as the medical records and grooming calendar.
Putting It All Together
Walk through the full flow to see the modules connect.
Open the Strapi Admin Panel and create a new Pet. Fill in the name, species, breed, and age, then leave the status as available. Because Draft and Publish is on, the pet starts as a draft. Upload a photo to the photo field; Strapi stores it in the Media Library. Uploading through REST is a two-step process: you first send a POST /api/upload request with multipart/form-data and receive back the file's numeric ID. You then create or update the Pet entry referencing that ID in the photo field. This split exists because uploading a file at entry creation time was removed in Strapi 5, so the two operations can no longer happen in one request. Files uploaded through REST land in an automatically created "API Uploads" folder.
Add a Vet Record linked to this pet, then attach a Vaccination with a nextDueDate. The relations resolve through the bidirectional chain you defined: pet to vet record to vaccination.
When the pet is ready, click Publish. The listing now appears at /adopt/[petId] on the public board, fully server-rendered with Open Graph tags. Visit the detail page, fill out the adoption form, and submit. The Server Action posts to the Adoption Application endpoint, and the new application shows up in the Admin Panel with status new.
Sign in to the dashboard at /login, then open /records/[petId] to review the pet's medical history. Try scheduling two overlapping grooming appointments: the first succeeds, the second returns a 409 and your form shows the conflict message. The custom availability service did its job before the database ever saw the colliding row.
Trace one pet end to end to see all three modules share the same backend. A draft pet becomes visible the moment you publish it, so a GET /api/pets from the public board returns it with no status parameter and renders it server-side with Open Graph tags. A visitor's inquiry hits POST /api/adoption-applications through the Server Action, writing a row with status new that staff read from the Admin Panel or the authenticated dashboard. When staff then book grooming, the POST /api/grooming-appointments request runs through the controller override, and a colliding slot returns the 409 before any write happens. One Strapi instance answers all three calls, each scoped by the permissions you set per role.
How Strapi Powers This
Strapi 5 serves as the single source of truth across all three modules of this platform. The Content-Type Builder lets you model the full pet-to-record-to-vaccination chain with bidirectional relations, while Draft and Publish controls which pets surface on the public adoption board without any custom filtering logic. The Document Service API powers the double-booking prevention service, querying existing appointments with $lt and $gt operators before any write reaches the database. Role-based permissions scope every endpoint: public visitors browse and apply, authenticated staff manage records and schedule grooming. Because Strapi exposes a flat REST API with explicit populate, Next.js 16 Server Components fetch exactly the data each page needs with no over-fetching.
To take your project further, deploy Strapi to Strapi Cloud and the frontend to Vercel, add email notifications for adoption inquiries using Strapi webhooks, or explore how to deploy Strapi to other hosting providers. The Strapi documentation and Strapi features page cover additional platform capabilities.


