Gift registries have a tricky requirement most apps don't: the owner should never know who claimed what. A wedding couple sees that the stand mixer is taken, but the surprise stays intact until the gift arrives. That surprise logic, plus public shareable pages that render nicely when someone drops the link in a group chat, is exactly the kind of public/private split that trips up developers who try to bolt it onto a generic CRUD setup.
This tutorial walks through building a gift registry and wishlist platform with Strapi and Next.js from the ground up. You model the content in Strapi 5, write a custom claiming controller that hides claimer identity, block double-claims with Document Service middleware, and render public registry pages in Next.js 16 with dynamic Open Graph previews. Owners get an authenticated dashboard for managing items and tracking who to thank.
In brief:
- Strapi 5 handles content modeling, the gift claiming system through custom controllers, and media storage for cover photos and item images.
- In Strapi, you can prevent double-claiming by implementing custom checks (for example in lifecycle hooks or route middleware) to compare claimed quantity against desired quantity before an increment is applied.
- Next.js 16 renders public registry pages with generateMetadata for social preview cards and Server Actions for the claiming flow.
- The public API surface returns claimed status without exposing claimer details, while a separate owner route reveals full claim data for thank-you tracking.
What We're Building
The platform lets a signed-in user create a registry for a wedding, baby shower, birthday, or housewarming. Each registry holds gift items with images, prices, external purchase links, and a desired quantity. Items group into categories like Kitchen, Bedroom, or Experience. Every registry gets a unique slug, so the owner can share a public URL like /registry/jane-and-alex-wedding that anyone can open without logging in.
Guests browse that public page and claim a gift. When a guest claims an item, Strapi creates a Claim record tied to that item, increments the claimed count, and stores the claimer's name and email. The registry owner's public-facing view shows the item as taken but never reveals who took it. Only the owner's authenticated dashboard exposes claim details, and that's strictly for sending thank-you notes after the event.
Strapi 5 manages the backend: the Content Types, the custom claim controller, the middleware that blocks double-claiming, and the Media Library for images. Next.js 16 renders the public pages with server-side rendering so social platforms get proper Open Graph tags, and it powers the authenticated management dashboard.
What you'll learn:
- Modeling Registry, GiftItem, Claim, and Category Content Types with relations and enumerations
- Writing a custom Strapi 5 controller with the Document Service API
- Registering a Document Service middleware to enforce business rules
- Configuring public versus owner API scopes so claimer identity stays hidden
- Rendering dynamic SEO and Open Graph tags with generateMetadata in Next.js 16
- Handling claims with Server Actions and optimistic UI updates
Prerequisites
You need these versions installed and a working knowledge of JavaScript, REST APIs, and React.
| Dependency | Version | Notes |
|---|---|---|
| Node.js | v22 LTS | Active LTS. Strapi 5 supports v20, v22, and v24. Avoid odd-numbered releases. |
| Strapi | 5.x (latest stable) | Verify with npx create-strapi@latest |
| Next.js | 16.2.x | App Router, Server Components, Server Actions |
| React | 19.2.x | React 19.2 features are supported in Next.js 16, which uses the latest React Canary release rather than shipping with a fixed React 19.2.x version. |
| Tailwind CSS | v4.x | Styling |
| PostgreSQL | 17.x | Production database |
A code editor with TypeScript support helps, since most of the frontend code here is typed. You should also be comfortable with the terminal and have npm v6 or above. Familiarity with the Strapi Admin Panel is useful but not required.
Setting Up the Strapi Backend
Step 1 — Install Strapi 5
Create the backend project. The interactive installer walks you through database setup and project configuration.
npx create-strapi@latest gift-registry-apiThe installer asks about your database. Pick PostgreSQL for production parity and supply your connection details. If you want to wire up PostgreSQL manually later, the database configuration lives in config/database.ts and uses a connection object passed to Knex.js plus a settings object for Strapi-specific options.
Once the install finishes, start the development server:
cd gift-registry-api
npm run developOpen http://localhost:1337/admin and create your administrator account. This is the Admin Panel where you'll model content and manage permissions.
Step 2 — Define the Registry, GiftItem, and Claim Content Types
You can build these through the Content Type Builder UI, but defining the schemas directly gives you exact control and makes the relations explicit. Strapi 5 stores each Content Type schema at ./src/api/api-name/content-types/content-type-name/schema.json.
Start with the Category Content Type. It groups gift items and carries a sort order.
{
"kind": "collectionType",
"collectionName": "categories",
"info": {
"singularName": "category",
"pluralName": "categories",
"displayName": "Category"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"name": {
"type": "string",
"required": true,
"unique": true
},
"sortOrder": {
"type": "integer",
"default": 0
},
"giftItems": {
"type": "relation",
"relation": "oneToMany",
"target": "api::gift-item.gift-item",
"mappedBy": "category"
}
}
}The Registry Content Type holds the slug, the type enumeration, the cover photo, an event date, and the public flag. The owner relation ties it to the built-in users-permissions user.
{
"kind": "collectionType",
"collectionName": "registries",
"info": {
"singularName": "registry",
"pluralName": "registries",
"displayName": "Registry"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"title": {
"type": "string",
"required": true,
"minLength": 3,
"maxLength": 120
},
"slug": {
"type": "uid",
"targetField": "title",
"required": true
},
"description": {
"type": "text"
},
"type": {
"type": "enumeration",
"enum": ["wedding", "baby-shower", "birthday", "housewarming"],
"default": "wedding",
"required": true
},
"cover": {
"type": "media",
"multiple": false,
"allowedTypes": ["images"]
},
"eventDate": {
"type": "datetime"
},
"isPublic": {
"type": "boolean",
"default": true
},
"owner": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
},
"giftItems": {
"type": "relation",
"relation": "oneToMany",
"target": "api::gift-item.gift-item",
"mappedBy": "registry"
}
}
}The GiftItem Content Type tracks the desired and claimed quantities. These two integers drive the double-claim check later. It also holds the external purchase URL, the price, the image, and the relations back to Registry and Category.
{
"kind": "collectionType",
"collectionName": "gift_items",
"info": {
"singularName": "gift-item",
"pluralName": "gift-items",
"displayName": "Gift Item"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"name": {
"type": "string",
"required": true
},
"description": {
"type": "text"
},
"image": {
"type": "media",
"multiple": false,
"allowedTypes": ["images"]
},
"externalUrl": {
"type": "string"
},
"price": {
"type": "decimal"
},
"quantityDesired": {
"type": "integer",
"default": 1,
"required": true
},
"quantityClaimed": {
"type": "integer",
"default": 0,
"required": true
},
"registry": {
"type": "relation",
"relation": "manyToOne",
"target": "api::registry.registry",
"inversedBy": "giftItems"
},
"category": {
"type": "relation",
"relation": "manyToOne",
"target": "api::category.category",
"inversedBy": "giftItems"
},
"claims": {
"type": "relation",
"relation": "oneToMany",
"target": "api::claim.claim",
"mappedBy": "giftItem"
}
}
}The Claim Content Type stores who claimed what. The claimer name and email stay private to the owner. The relation points back to the gift item.
{
"kind": "collectionType",
"collectionName": "claims",
"info": {
"singularName": "claim",
"pluralName": "claims",
"displayName": "Claim"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"claimerName": {
"type": "string",
"required": true
},
"claimerEmail": {
"type": "email",
"required": true
},
"message": {
"type": "text"
},
"claimedAt": {
"type": "datetime"
},
"giftItem": {
"type": "relation",
"relation": "manyToOne",
"target": "api::gift-item.gift-item",
"inversedBy": "claims"
}
}
}Restart the server after editing schema files so Strapi reloads the content-type definitions and applies supported database schema updates, allowing the content modeling layer to pick up the relations.
Step 3 — Build the Gift Claiming Controller
The default create endpoint on the Claim Content Type won't do here. You need a single operation that creates a Claim, increments quantityClaimed on the gift item, and returns a clean success response that never leaks claimer identity back into the registry owner's normal API responses.
Strapi 5 uses the Document Service API. The Entity Service API from v4 is deprecated, so every data access call here goes through strapi.documents().
Add a custom action to the gift-item controller.
// src/api/gift-item/controllers/gift-item.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreController(
'api::gift-item.gift-item',
({ strapi }) => ({
async claim(ctx) {
const { documentId } = ctx.params;
const { claimerName, claimerEmail, message } = ctx.request.body?.data ?? {};
if (!claimerName || !claimerEmail) {
return ctx.badRequest('claimerName and claimerEmail are required.');
}
const giftItem = await strapi
.documents('api::gift-item.gift-item')
.findOne({
documentId,
fields: ['quantityDesired', 'quantityClaimed', 'name'],
});
if (!giftItem) {
return ctx.notFound('Gift item not found.');
}
const claim = await strapi.documents('api::claim.claim').create({
data: {
claimerName,
claimerEmail,
message,
claimedAt: new Date().toISOString(),
giftItem: { connect: [{ documentId }] },
},
});
await strapi.documents('api::gift-item.gift-item').update({
documentId,
data: {
quantityClaimed: giftItem.quantityClaimed + 1,
},
});
return {
data: {
documentId: claim.documentId,
claimed: true,
itemName: giftItem.name,
},
};
},
})
);Register the custom route so the action is reachable. Set auth: false because guests claim gifts without logging in.
// src/api/gift-item/routes/claim.ts
export default {
routes: [
{
method: 'POST',
path: '/gift-items/:documentId/claim',
handler: 'gift-item.claim',
config: {
auth: false,
},
},
],
};This separates the claiming flow from the standard CRUD controller routes. The response intentionally omits the Claim's claimerName and claimerEmail, so even if a curious owner inspects the network request, the surprise holds.
Step 4 — Prevent Double-Claiming with Document Service Middleware
A guest shouldn't be able to claim an item that's already fully claimed. Race conditions and double submissions make this a real concern, not a theoretical one. Strapi 5 recommends handling this through Document Service middlewares, though lifecycle hooks are still available but behave differently than in v4.
Register the middleware in the application's register() lifecycle during the Strapi registration phase, before the server starts accepting requests. The middleware watches for Claim creation, looks up the related gift item, and throws if the item has already been claimed.
// src/index.ts
import type { Core } from '@strapi/strapi';
export default {
register({ strapi }: { strapi: Core.Strapi }) {
strapi.documents.use(async (context, next) => {
if (context.uid !== 'api::claim.claim' || context.action !== 'create') {
return next();
}
const giftItemRelation = context.params?.data?.giftItem;
const documentId =
giftItemRelation?.connect?.[0]?.documentId ?? giftItemRelation;
if (!documentId) {
throw new Error('A gift item is required to create a claim.');
}
const giftItem = await strapi
.documents('api::gift-item.gift-item')
.findOne({
documentId,
fields: ['quantityDesired', 'quantityClaimed'],
});
if (!giftItem) {
throw new Error('Gift item not found.');
}
if (giftItem.quantityClaimed >= giftItem.quantityDesired) {
throw new Error('This item has already been fully claimed.');
}
return next();
});
},
bootstrap() {},
};The check happens before next() runs, so a failed validation short-circuits the whole operation. The Claim never gets created and the controller's increment never fires. Because the middleware sits at the Document Service layer, it protects the gift item regardless of which controller or API call triggers the claim.
Step 5 — Configure Public vs Owner API Scopes
The public registry page needs gift items with claimed status, but never claim details. The owner's dashboard needs full claim records for thank-you tracking. You handle this split with explicit populate and route permissions.
First, open the Admin Panel and head to Settings > Users & Permissions > Roles. For the Public role, enable find and findOne on Registry and GiftItem, plus find on Category. Leave Claim disabled for the public role entirely, so claim records never surface in public queries. For the Authenticated role, enable the same plus find on Claim.
The public registry fetch populates gift items and the cover image but never the claims relation:
GET /api/registries?filters[slug][$eq]=jane-and-alex-wedding&populate[cover]=true&populate[giftItems][populate][image]=true&populate[giftItems][populate][category]=trueStrapi 5 recommends using explicit populate (instead of populate=*), as it improves performance and predictability, and helps ensure only the necessary data is exposed in public responses. Note that Strapi also enforces permissions and field-level privacy, so sensitive data won't be exposed unless the content-type and fields are publicly accessible and not marked private. The flat REST format means attributes sit directly on the object, and you reference records by documentId rather than id.
For the owner dashboard, a separate query includes claims because the authenticated role has permission:
GET /api/registries?filters[slug][$eq]=jane-and-alex-wedding&populate[giftItems][populate][claims]=trueTo make sure owners only fetch their own registries with claim details, add a policy that checks ownership. Strapi runs route policies before the controller.
// src/api/registry/policies/is-owner.js
module.exports = (policyContext, config, { strapi }) => {
const user = policyContext.state.user;
if (!user) {
return false;
}
return true;
};Note the find permission requirement: if a role lacks access to a Content Type, Strapi won't populate it even when you ask. That's the mechanism keeping claim data out of public responses. The public role simply can't read Claim, so the relation comes back empty.
Building the Next.js 16 Frontend
Step 1 — Set Up the Next.js 16 Project
Create the frontend in a separate directory from the Strapi backend.
npx create-next-app@latest gift-registry-webChoose TypeScript, the App Router, and Tailwind when prompted. If you prefer to add Tailwind v4 manually, install the PostCSS plugin:
npm install tailwindcss @tailwindcss/postcss postcssTailwind v4 uses CSS-first configuration. Add the PostCSS plugin config:
// postcss.config.mjs
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};Then import Tailwind in your global stylesheet:
/* app/globals.css */
@import "tailwindcss";Point the app at your Strapi instance with an environment variable:
# .env.local
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337Create a small data helper that both the public page and the dashboard reuse.
// app/lib/data.ts
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL;
export type GiftItem = {
documentId: string;
name: string;
description: string | null;
price: number | null;
externalUrl: string | null;
quantityDesired: number;
quantityClaimed: number;
image: { url: string } | null;
category: { name: string } | null;
};
export type Registry = {
documentId: string;
title: string;
slug: string;
description: string | null;
type: string;
eventDate: string | null;
cover: { url: string } | null;
giftItems: GiftItem[];
};
export async function getRegistry(slug: string): Promise<Registry | null> {
const query =
`filters[slug][$eq]=${slug}` +
`&populate[cover]=true` +
`&populate[giftItems][populate][image]=true` +
`&populate[giftItems][populate][category]=true`;
const res = await fetch(`${STRAPI_URL}/api/registries?${query}`, {
next: { revalidate: 60 },
});
if (!res.ok) return null;
const json = await res.json();
return json.data?.[0] ?? null;
}The next: { revalidate: 60 } option caches the response for 60 seconds, then revalidates in the background. That keeps the public page fast while picking up new claims reasonably quickly.
Step 2 — Build the Public Registry Page
The public page lives at app/registry/slug/page.tsx. In Next.js 16, params is a Promise and must be awaited, both in the page component and in generateMetadata. The generateMetadata export produces the dynamic title and Open Graph tags that turn a pasted link into a rich preview card.
// app/registry/[slug]/page.tsx
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { getRegistry } from '@/app/lib/data';
import { GiftList } from '@/app/components/gift-list';
type Props = {
params: Promise<{ slug: string }>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const registry = await getRegistry(slug);
if (!registry) {
return { title: 'Registry not found' };
}
const coverUrl = registry.cover
? `${process.env.NEXT_PUBLIC_STRAPI_URL}${registry.cover.url}`
: undefined;
return {
title: registry.title,
description: registry.description ?? 'View this gift registry.',
openGraph: {
title: registry.title,
description: registry.description ?? 'View this gift registry.',
images: coverUrl ? [coverUrl] : [],
},
};
}
export default async function RegistryPage({ params }: Props) {
const { slug } = await params;
const registry = await getRegistry(slug);
if (!registry) {
notFound();
}
const coverUrl = registry.cover
? `${process.env.NEXT_PUBLIC_STRAPI_URL}${registry.cover.url}`
: null;
return (
<main className="mx-auto max-w-4xl px-4 py-10">
{coverUrl && (
<img
src={coverUrl}
alt={`Cover photo for ${registry.title}`}
className="mb-6 h-64 w-full rounded-lg object-cover"
/>
)}
<h1 className="text-3xl font-bold">{registry.title}</h1>
{registry.description && (
<p className="mt-2 text-gray-600">{registry.description}</p>
)}
<GiftList items={registry.giftItems} slug={registry.slug} />
</main>
);
}The generated HTML carries the og:title, og:description, and og:image tags that social platforms read. Because the page renders server-side, those tags are present in the initial HTML, so the preview card works when someone shares the link.
The gift grid is a Client Component because it manages the claim modal and optimistic state. Here's the markup that renders each item with its claimed or available status:
'use client';
// app/components/gift-list.tsx
import { useOptimistic, useState } from 'react';
import { claimItem } from '@/app/actions';
import type { GiftItem } from '@/app/lib/data';
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL;
export function GiftList({
items,
slug,
}: {
items: GiftItem[];
slug: string;
}) {
const [optimisticItems, markClaimed] = useOptimistic(
items,
(state: GiftItem[], claimedId: string) =>
state.map((item) =>
item.documentId === claimedId
? { ...item, quantityClaimed: item.quantityClaimed + 1 }
: item
)
);
const [activeItem, setActiveItem] = useState<GiftItem | null>(null);
return (
<>
<ul className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2">
{optimisticItems.map((item) => {
const isAvailable = item.quantityClaimed < item.quantityDesired;
const imageUrl = item.image
? `${STRAPI_URL}${item.image.url}`
: null;
return (
<li
key={item.documentId}
className="rounded-lg border border-gray-200 p-4"
>
{imageUrl && (
<img
src={imageUrl}
alt={item.name}
className="mb-3 h-40 w-full rounded object-cover"
/>
)}
<h2 className="font-semibold">{item.name}</h2>
{item.price != null && (
<p className="text-gray-600">
${item.price.toFixed(2)} USD
</p>
)}
{isAvailable ? (
<button
onClick={() => setActiveItem(item)}
className="mt-3 rounded bg-black px-4 py-2 text-white"
>
Claim this gift
</button>
) : (
<span className="mt-3 inline-block text-green-700">
Claimed
</span>
)}
</li>
);
})}
</ul>
{activeItem && (
<ClaimModal
item={activeItem}
slug={slug}
onClaimed={(id) => markClaimed(id)}
onClose={() => setActiveItem(null)}
/>
)}
</>
);
}
function ClaimModal({
item,
slug,
onClaimed,
onClose,
}: {
item: GiftItem;
slug: string;
onClaimed: (id: string) => void;
onClose: () => void;
}) {
return (
<div className="fixed inset-0 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg bg-white p-6">
<h3 className="text-lg font-semibold">Claim {item.name}</h3>
<form
action={async (formData: FormData) => {
onClaimed(item.documentId);
await claimItem(formData);
onClose();
}}
className="mt-4 space-y-3"
>
<input type="hidden" name="documentId" value={item.documentId} />
<input type="hidden" name="slug" value={slug} />
<input
type="text"
name="claimerName"
placeholder="Your name"
required
className="w-full rounded border px-3 py-2"
/>
<input
type="email"
name="claimerEmail"
placeholder="Your email"
required
className="w-full rounded border px-3 py-2"
/>
<textarea
name="message"
placeholder="Add a message (optional)"
className="w-full rounded border px-3 py-2"
/>
<div className="flex gap-2">
<button
type="submit"
className="rounded bg-black px-4 py-2 text-white"
>
Confirm claim
</button>
<button
type="button"
onClick={onClose}
className="rounded border px-4 py-2"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}The shareable URL is just the slug-based route. Owners copy /registry/their-slug and send it anywhere.
Step 3 — Build the Registry Management Dashboard
The dashboard sits behind authentication. Next.js 16 favors a Data Access Layer pattern that memoizes session verification with React's cache API. Set up the session check first.
// app/lib/dal.ts
import 'server-only';
import { cookies } from 'next/headers';
import { cache } from 'react';
import { redirect } from 'next/navigation';
export const verifySession = cache(async () => {
const token = (await cookies()).get('session')?.value;
if (!token) {
redirect('/login');
}
return { isAuth: true, token };
});The dashboard page fetches the owner's registries with full claim details, since the authenticated role has permission to read Claim. The owner sees how many items are claimed and, on a per-item view, who claimed each one for thank-you notes.
// app/dashboard/page.tsx
import { verifySession } from '@/app/lib/dal';
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL;
type Claim = {
documentId: string;
claimerName: string;
claimerEmail: string;
message: string | null;
};
type OwnerGiftItem = {
documentId: string;
name: string;
quantityDesired: number;
quantityClaimed: number;
claims: Claim[];
};
type OwnerRegistry = {
documentId: string;
title: string;
slug: string;
giftItems: OwnerGiftItem[];
};
async function getOwnerRegistries(token: string): Promise<OwnerRegistry[]> {
const query = `populate[giftItems][populate][claims]=true`;
const res = await fetch(`${STRAPI_URL}/api/registries?${query}`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
});
if (!res.ok) return [];
const json = await res.json();
return json.data ?? [];
}
export default async function DashboardPage() {
const { token } = await verifySession();
const registries = await getOwnerRegistries(token);
return (
<main className="mx-auto max-w-4xl px-4 py-10">
<h1 className="text-2xl font-bold">Your registries</h1>
{registries.map((registry) => (
<section key={registry.documentId} className="mt-8">
<h2 className="text-xl font-semibold">{registry.title}</h2>
<ul className="mt-4 space-y-3">
{registry.giftItems.map((item) => (
<li
key={item.documentId}
className="rounded border border-gray-200 p-4"
>
<div className="flex justify-between">
<span>{item.name}</span>
<span>
{item.quantityClaimed} / {item.quantityDesired} claimed
</span>
</div>
{item.claims.length > 0 && (
<ul className="mt-2 text-sm text-gray-600">
{item.claims.map((claim) => (
<li key={claim.documentId}>
Thank {claim.claimerName} ({claim.claimerEmail})
</li>
))}
</ul>
)}
</li>
))}
</ul>
</section>
))}
</main>
);
}Adding gift items with images uses the two-step upload pattern. Strapi 5 no longer supports uploading a file at entry creation, so you upload the image first, get a file ID back, then create the gift item referencing that ID. The upload endpoint expects multipart/form-data.
'use server';
// app/dashboard/actions.ts
import { verifySession } from '@/app/lib/dal';
import { revalidatePath } from 'next/cache';
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL;
export async function addGiftItem(formData: FormData) {
const { token } = await verifySession();
const name = formData.get('name') as string;
const price = formData.get('price') as string;
const externalUrl = formData.get('externalUrl') as string;
const registryDocumentId = formData.get('registryDocumentId') as string;
const image = formData.get('image') as File;
let imageId: number | null = null;
if (image && image.size > 0) {
const uploadForm = new FormData();
uploadForm.append('files', image, image.name);
const uploadRes = await fetch(`${STRAPI_URL}/api/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: uploadForm,
});
const uploaded = await uploadRes.json();
imageId = uploaded?.[0]?.id ?? null;
}
await fetch(`${STRAPI_URL}/api/gift-items`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
data: {
name,
price: price ? parseFloat(price) : null,
externalUrl,
quantityDesired: 1,
quantityClaimed: 0,
image: imageId,
registry: { connect: [{ documentId: registryDocumentId }] },
},
}),
});
revalidatePath('/dashboard');
}Strapi 5 supports several ways to link entries, with connect arrays of documentId objects being one valid pattern but not required. For media fields, the image field takes the numeric file ID returned from the upload, since connect is not officially supported for media attributes — use the two-step upload → create pattern instead.
Step 4 — Handle Gift Claiming with Server Actions
The claim action calls the custom controller endpoint from Step 3. It runs on the server, so it can hit Strapi directly without exposing the call to the browser. After the claim succeeds, it revalidates the public page so the next visitor sees the updated count.
'use server';
// app/actions.ts
import { revalidatePath } from 'next/cache';
const STRAPI_URL = process.env.STRAPI_URL;
export async function claimItem(formData: FormData) {
const documentId = formData.get('documentId') as string;
const slug = formData.get('slug') as string;
const claimerName = formData.get('claimerName') as string;
const claimerEmail = formData.get('claimerEmail') as string;
const message = formData.get('message') as string;
const res = await fetch(`${STRAPI_URL}/api/gift-items/${documentId}/claim`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
data: { claimerName, claimerEmail, message },
}),
});
if (!res.ok) {
throw new Error('This item could not be claimed. It may already be taken.');
}
revalidatePath(`/registry/${slug}`);
}The optimistic update happens in the form's action handler back in gift-list.tsx. Because the markClaimed call sits inside an Action prop passed to <form action={...}>, you don't need to wrap it in startTransition. The UI flips the item to claimed immediately, the Server Action runs, and revalidatePath refreshes the underlying data. If the middleware rejects a double claim, the action throws and the optimistic state reverts on the next render.
Putting It All Together
Time to run the full flow. Start both servers: npm run develop in the Strapi directory and npm run dev in the Next.js directory.
In the Strapi Admin Panel, create a few categories like Kitchen and Experience. Then create a registry through the dashboard or directly in the Admin Panel. Set the title to something like "Jane and Alex's Wedding Registry," pick the wedding type, upload a cover photo, and set isPublic to true. Strapi generates the slug from the title.
Add gift items through the dashboard form. Upload an image for each, set a price, add an external purchase link, and assign a category. Watch the two-step upload pattern in action: the image uploads first, then the gift item is created with the returned file ID.
Open the public URL at http://localhost:3000/registry/jane-and-alexs-wedding-registry. The page renders server-side with the cover photo, the gift grid, and proper Open Graph tags. Paste that URL into a tool that previews link cards, and you'll see the registry title, description, and cover image pulled from generateMetadata.
Now claim a gift as a guest. Click "Claim this gift," fill in your name, email, and an optional message, then confirm. The item flips to "Claimed" immediately through the optimistic update. Refresh the page and the status holds, because the Server Action revalidated the cached data.
Switch to the owner's dashboard. The owner sees the item marked as 1 of 1 claimed, but the public registry page never showed who claimed it. Only the dashboard, fetching with the authenticated token and the claims populate, reveals the claimer's name and email for thank-you tracking. The surprise stays intact on the public surface while the owner gets exactly what they need after the event.
Next Steps
The platform works, but there's room to grow. Deploy the backend to Strapi Cloud and the frontend to Vercel for a managed, git-integrated workflow. Add email notifications for new claims by triggering Strapi webhooks that hit an email service. Affiliate links on the external purchase URLs open a revenue path. A group gifting feature would let several guests split the cost of an expensive item by tracking partial contributions against the price. Registry analytics could surface which items get viewed most versus claimed.
For deeper reference, the Strapi documentation covers the Document Service API, middlewares, and the Media Library in detail. The Next.js documentation goes further on caching models, Server Actions, and metadata. The Strapi integrations page lists more frontend pairings if you want to try a different framework, and the Strapi Marketplace has plugins for email providers and analytics.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.