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:
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:
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.
Create the backend project. The interactive installer walks you through database setup and project configuration.
1npx 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:
1cd gift-registry-api
2npm run developOpen http://localhost:1337/admin and create your administrator account. This is the Admin Panel where you'll model content and manage permissions.
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.
1{
2 "kind": "collectionType",
3 "collectionName": "categories",
4 "info": {
5 "singularName": "category",
6 "pluralName": "categories",
7 "displayName": "Category"
8 },
9 "options": {
10 "draftAndPublish": false
11 },
12 "attributes": {
13 "name": {
14 "type": "string",
15 "required": true,
16 "unique": true
17 },
18 "sortOrder": {
19 "type": "integer",
20 "default": 0
21 },
22 "giftItems": {
23 "type": "relation",
24 "relation": "oneToMany",
25 "target": "api::gift-item.gift-item",
26 "mappedBy": "category"
27 }
28 }
29}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.
1{
2 "kind": "collectionType",
3 "collectionName": "registries",
4 "info": {
5 "singularName": "registry",
6 "pluralName": "registries",
7 "displayName": "Registry"
8 },
9 "options": {
10 "draftAndPublish": false
11 },
12 "attributes": {
13 "title": {
14 "type": "string",
15 "required": true,
16 "minLength": 3,
17 "maxLength": 120
18 },
19 "slug": {
20 "type": "uid",
21 "targetField": "title",
22 "required": true
23 },
24 "description": {
25 "type": "text"
26 },
27 "type": {
28 "type": "enumeration",
29 "enum": ["wedding", "baby-shower", "birthday", "housewarming"],
30 "default": "wedding",
31 "required": true
32 },
33 "cover": {
34 "type": "media",
35 "multiple": false,
36 "allowedTypes": ["images"]
37 },
38 "eventDate": {
39 "type": "datetime"
40 },
41 "isPublic": {
42 "type": "boolean",
43 "default": true
44 },
45 "owner": {
46 "type": "relation",
47 "relation": "manyToOne",
48 "target": "plugin::users-permissions.user"
49 },
50 "giftItems": {
51 "type": "relation",
52 "relation": "oneToMany",
53 "target": "api::gift-item.gift-item",
54 "mappedBy": "registry"
55 }
56 }
57}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.
1{
2 "kind": "collectionType",
3 "collectionName": "gift_items",
4 "info": {
5 "singularName": "gift-item",
6 "pluralName": "gift-items",
7 "displayName": "Gift Item"
8 },
9 "options": {
10 "draftAndPublish": false
11 },
12 "attributes": {
13 "name": {
14 "type": "string",
15 "required": true
16 },
17 "description": {
18 "type": "text"
19 },
20 "image": {
21 "type": "media",
22 "multiple": false,
23 "allowedTypes": ["images"]
24 },
25 "externalUrl": {
26 "type": "string"
27 },
28 "price": {
29 "type": "decimal"
30 },
31 "quantityDesired": {
32 "type": "integer",
33 "default": 1,
34 "required": true
35 },
36 "quantityClaimed": {
37 "type": "integer",
38 "default": 0,
39 "required": true
40 },
41 "registry": {
42 "type": "relation",
43 "relation": "manyToOne",
44 "target": "api::registry.registry",
45 "inversedBy": "giftItems"
46 },
47 "category": {
48 "type": "relation",
49 "relation": "manyToOne",
50 "target": "api::category.category",
51 "inversedBy": "giftItems"
52 },
53 "claims": {
54 "type": "relation",
55 "relation": "oneToMany",
56 "target": "api::claim.claim",
57 "mappedBy": "giftItem"
58 }
59 }
60}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.
1{
2 "kind": "collectionType",
3 "collectionName": "claims",
4 "info": {
5 "singularName": "claim",
6 "pluralName": "claims",
7 "displayName": "Claim"
8 },
9 "options": {
10 "draftAndPublish": false
11 },
12 "attributes": {
13 "claimerName": {
14 "type": "string",
15 "required": true
16 },
17 "claimerEmail": {
18 "type": "email",
19 "required": true
20 },
21 "message": {
22 "type": "text"
23 },
24 "claimedAt": {
25 "type": "datetime"
26 },
27 "giftItem": {
28 "type": "relation",
29 "relation": "manyToOne",
30 "target": "api::gift-item.gift-item",
31 "inversedBy": "claims"
32 }
33 }
34}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.
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.
1// src/api/gift-item/controllers/gift-item.ts
2import { factories } from '@strapi/strapi';
3
4export default factories.createCoreController(
5 'api::gift-item.gift-item',
6 ({ strapi }) => ({
7 async claim(ctx) {
8 const { documentId } = ctx.params;
9 const { claimerName, claimerEmail, message } = ctx.request.body?.data ?? {};
10
11 if (!claimerName || !claimerEmail) {
12 return ctx.badRequest('claimerName and claimerEmail are required.');
13 }
14
15 const giftItem = await strapi
16 .documents('api::gift-item.gift-item')
17 .findOne({
18 documentId,
19 fields: ['quantityDesired', 'quantityClaimed', 'name'],
20 });
21
22 if (!giftItem) {
23 return ctx.notFound('Gift item not found.');
24 }
25
26 const claim = await strapi.documents('api::claim.claim').create({
27 data: {
28 claimerName,
29 claimerEmail,
30 message,
31 claimedAt: new Date().toISOString(),
32 giftItem: { connect: [{ documentId }] },
33 },
34 });
35
36 await strapi.documents('api::gift-item.gift-item').update({
37 documentId,
38 data: {
39 quantityClaimed: giftItem.quantityClaimed + 1,
40 },
41 });
42
43 return {
44 data: {
45 documentId: claim.documentId,
46 claimed: true,
47 itemName: giftItem.name,
48 },
49 };
50 },
51 })
52);Register the custom route so the action is reachable. Set auth: false because guests claim gifts without logging in.
1// src/api/gift-item/routes/claim.ts
2export default {
3 routes: [
4 {
5 method: 'POST',
6 path: '/gift-items/:documentId/claim',
7 handler: 'gift-item.claim',
8 config: {
9 auth: false,
10 },
11 },
12 ],
13};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.
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.
1// src/index.ts
2import type { Core } from '@strapi/strapi';
3
4export default {
5 register({ strapi }: { strapi: Core.Strapi }) {
6 strapi.documents.use(async (context, next) => {
7 if (context.uid !== 'api::claim.claim' || context.action !== 'create') {
8 return next();
9 }
10
11 const giftItemRelation = context.params?.data?.giftItem;
12 const documentId =
13 giftItemRelation?.connect?.[0]?.documentId ?? giftItemRelation;
14
15 if (!documentId) {
16 throw new Error('A gift item is required to create a claim.');
17 }
18
19 const giftItem = await strapi
20 .documents('api::gift-item.gift-item')
21 .findOne({
22 documentId,
23 fields: ['quantityDesired', 'quantityClaimed'],
24 });
25
26 if (!giftItem) {
27 throw new Error('Gift item not found.');
28 }
29
30 if (giftItem.quantityClaimed >= giftItem.quantityDesired) {
31 throw new Error('This item has already been fully claimed.');
32 }
33
34 return next();
35 });
36 },
37
38 bootstrap() {},
39};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.
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:
1GET /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:
1GET /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.
1// src/api/registry/policies/is-owner.js
2module.exports = (policyContext, config, { strapi }) => {
3 const user = policyContext.state.user;
4 if (!user) {
5 return false;
6 }
7 return true;
8};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.
Create the frontend in a separate directory from the Strapi backend.
1npx 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:
1npm install tailwindcss @tailwindcss/postcss postcssTailwind v4 uses CSS-first configuration. Add the PostCSS plugin config:
1// postcss.config.mjs
2export default {
3 plugins: {
4 '@tailwindcss/postcss': {},
5 },
6};Then import Tailwind in your global stylesheet:
1/* app/globals.css */
2@import "tailwindcss";Point the app at your Strapi instance with an environment variable:
1# .env.local
2NEXT_PUBLIC_STRAPI_URL=http://localhost:1337Create a small data helper that both the public page and the dashboard reuse.
1// app/lib/data.ts
2const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL;
3
4export type GiftItem = {
5 documentId: string;
6 name: string;
7 description: string | null;
8 price: number | null;
9 externalUrl: string | null;
10 quantityDesired: number;
11 quantityClaimed: number;
12 image: { url: string } | null;
13 category: { name: string } | null;
14};
15
16export type Registry = {
17 documentId: string;
18 title: string;
19 slug: string;
20 description: string | null;
21 type: string;
22 eventDate: string | null;
23 cover: { url: string } | null;
24 giftItems: GiftItem[];
25};
26
27export async function getRegistry(slug: string): Promise<Registry | null> {
28 const query =
29 `filters[slug][$eq]=${slug}` +
30 `&populate[cover]=true` +
31 `&populate[giftItems][populate][image]=true` +
32 `&populate[giftItems][populate][category]=true`;
33
34 const res = await fetch(`${STRAPI_URL}/api/registries?${query}`, {
35 next: { revalidate: 60 },
36 });
37
38 if (!res.ok) return null;
39
40 const json = await res.json();
41 return json.data?.[0] ?? null;
42}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.
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.
1// app/registry/[slug]/page.tsx
2import { notFound } from 'next/navigation';
3import type { Metadata } from 'next';
4import { getRegistry } from '@/app/lib/data';
5import { GiftList } from '@/app/components/gift-list';
6
7type Props = {
8 params: Promise<{ slug: string }>;
9};
10
11export async function generateMetadata({ params }: Props): Promise<Metadata> {
12 const { slug } = await params;
13 const registry = await getRegistry(slug);
14
15 if (!registry) {
16 return { title: 'Registry not found' };
17 }
18
19 const coverUrl = registry.cover
20 ? `${process.env.NEXT_PUBLIC_STRAPI_URL}${registry.cover.url}`
21 : undefined;
22
23 return {
24 title: registry.title,
25 description: registry.description ?? 'View this gift registry.',
26 openGraph: {
27 title: registry.title,
28 description: registry.description ?? 'View this gift registry.',
29 images: coverUrl ? [coverUrl] : [],
30 },
31 };
32}
33
34export default async function RegistryPage({ params }: Props) {
35 const { slug } = await params;
36 const registry = await getRegistry(slug);
37
38 if (!registry) {
39 notFound();
40 }
41
42 const coverUrl = registry.cover
43 ? `${process.env.NEXT_PUBLIC_STRAPI_URL}${registry.cover.url}`
44 : null;
45
46 return (
47 <main className="mx-auto max-w-4xl px-4 py-10">
48 {coverUrl && (
49 <img
50 src={coverUrl}
51 alt={`Cover photo for ${registry.title}`}
52 className="mb-6 h-64 w-full rounded-lg object-cover"
53 />
54 )}
55 <h1 className="text-3xl font-bold">{registry.title}</h1>
56 {registry.description && (
57 <p className="mt-2 text-gray-600">{registry.description}</p>
58 )}
59 <GiftList items={registry.giftItems} slug={registry.slug} />
60 </main>
61 );
62}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:
1'use client';
2// app/components/gift-list.tsx
3
4import { useOptimistic, useState } from 'react';
5import { claimItem } from '@/app/actions';
6import type { GiftItem } from '@/app/lib/data';
7
8const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL;
9
10export function GiftList({
11 items,
12 slug,
13}: {
14 items: GiftItem[];
15 slug: string;
16}) {
17 const [optimisticItems, markClaimed] = useOptimistic(
18 items,
19 (state: GiftItem[], claimedId: string) =>
20 state.map((item) =>
21 item.documentId === claimedId
22 ? { ...item, quantityClaimed: item.quantityClaimed + 1 }
23 : item
24 )
25 );
26
27 const [activeItem, setActiveItem] = useState<GiftItem | null>(null);
28
29 return (
30 <>
31 <ul className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2">
32 {optimisticItems.map((item) => {
33 const isAvailable = item.quantityClaimed < item.quantityDesired;
34 const imageUrl = item.image
35 ? `${STRAPI_URL}${item.image.url}`
36 : null;
37
38 return (
39 <li
40 key={item.documentId}
41 className="rounded-lg border border-gray-200 p-4"
42 >
43 {imageUrl && (
44 <img
45 src={imageUrl}
46 alt={item.name}
47 className="mb-3 h-40 w-full rounded object-cover"
48 />
49 )}
50 <h2 className="font-semibold">{item.name}</h2>
51 {item.price != null && (
52 <p className="text-gray-600">
53 ${item.price.toFixed(2)} USD
54 </p>
55 )}
56 {isAvailable ? (
57 <button
58 onClick={() => setActiveItem(item)}
59 className="mt-3 rounded bg-black px-4 py-2 text-white"
60 >
61 Claim this gift
62 </button>
63 ) : (
64 <span className="mt-3 inline-block text-green-700">
65 Claimed
66 </span>
67 )}
68 </li>
69 );
70 })}
71 </ul>
72
73 {activeItem && (
74 <ClaimModal
75 item={activeItem}
76 slug={slug}
77 onClaimed={(id) => markClaimed(id)}
78 onClose={() => setActiveItem(null)}
79 />
80 )}
81 </>
82 );
83}
84
85function ClaimModal({
86 item,
87 slug,
88 onClaimed,
89 onClose,
90}: {
91 item: GiftItem;
92 slug: string;
93 onClaimed: (id: string) => void;
94 onClose: () => void;
95}) {
96 return (
97 <div className="fixed inset-0 flex items-center justify-center bg-black/50">
98 <div className="w-full max-w-md rounded-lg bg-white p-6">
99 <h3 className="text-lg font-semibold">Claim {item.name}</h3>
100 <form
101 action={async (formData: FormData) => {
102 onClaimed(item.documentId);
103 await claimItem(formData);
104 onClose();
105 }}
106 className="mt-4 space-y-3"
107 >
108 <input type="hidden" name="documentId" value={item.documentId} />
109 <input type="hidden" name="slug" value={slug} />
110 <input
111 type="text"
112 name="claimerName"
113 placeholder="Your name"
114 required
115 className="w-full rounded border px-3 py-2"
116 />
117 <input
118 type="email"
119 name="claimerEmail"
120 placeholder="Your email"
121 required
122 className="w-full rounded border px-3 py-2"
123 />
124 <textarea
125 name="message"
126 placeholder="Add a message (optional)"
127 className="w-full rounded border px-3 py-2"
128 />
129 <div className="flex gap-2">
130 <button
131 type="submit"
132 className="rounded bg-black px-4 py-2 text-white"
133 >
134 Confirm claim
135 </button>
136 <button
137 type="button"
138 onClick={onClose}
139 className="rounded border px-4 py-2"
140 >
141 Cancel
142 </button>
143 </div>
144 </form>
145 </div>
146 </div>
147 );
148}The shareable URL is just the slug-based route. Owners copy /registry/their-slug and send it anywhere.
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.
1// app/lib/dal.ts
2import 'server-only';
3import { cookies } from 'next/headers';
4import { cache } from 'react';
5import { redirect } from 'next/navigation';
6
7export const verifySession = cache(async () => {
8 const token = (await cookies()).get('session')?.value;
9
10 if (!token) {
11 redirect('/login');
12 }
13
14 return { isAuth: true, token };
15});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.
1// app/dashboard/page.tsx
2import { verifySession } from '@/app/lib/dal';
3
4const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL;
5
6type Claim = {
7 documentId: string;
8 claimerName: string;
9 claimerEmail: string;
10 message: string | null;
11};
12
13type OwnerGiftItem = {
14 documentId: string;
15 name: string;
16 quantityDesired: number;
17 quantityClaimed: number;
18 claims: Claim[];
19};
20
21type OwnerRegistry = {
22 documentId: string;
23 title: string;
24 slug: string;
25 giftItems: OwnerGiftItem[];
26};
27
28async function getOwnerRegistries(token: string): Promise<OwnerRegistry[]> {
29 const query = `populate[giftItems][populate][claims]=true`;
30
31 const res = await fetch(`${STRAPI_URL}/api/registries?${query}`, {
32 headers: { Authorization: `Bearer ${token}` },
33 cache: 'no-store',
34 });
35
36 if (!res.ok) return [];
37
38 const json = await res.json();
39 return json.data ?? [];
40}
41
42export default async function DashboardPage() {
43 const { token } = await verifySession();
44 const registries = await getOwnerRegistries(token);
45
46 return (
47 <main className="mx-auto max-w-4xl px-4 py-10">
48 <h1 className="text-2xl font-bold">Your registries</h1>
49 {registries.map((registry) => (
50 <section key={registry.documentId} className="mt-8">
51 <h2 className="text-xl font-semibold">{registry.title}</h2>
52 <ul className="mt-4 space-y-3">
53 {registry.giftItems.map((item) => (
54 <li
55 key={item.documentId}
56 className="rounded border border-gray-200 p-4"
57 >
58 <div className="flex justify-between">
59 <span>{item.name}</span>
60 <span>
61 {item.quantityClaimed} / {item.quantityDesired} claimed
62 </span>
63 </div>
64 {item.claims.length > 0 && (
65 <ul className="mt-2 text-sm text-gray-600">
66 {item.claims.map((claim) => (
67 <li key={claim.documentId}>
68 Thank {claim.claimerName} ({claim.claimerEmail})
69 </li>
70 ))}
71 </ul>
72 )}
73 </li>
74 ))}
75 </ul>
76 </section>
77 ))}
78 </main>
79 );
80}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.
1'use server';
2// app/dashboard/actions.ts
3
4import { verifySession } from '@/app/lib/dal';
5import { revalidatePath } from 'next/cache';
6
7const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL;
8
9export async function addGiftItem(formData: FormData) {
10 const { token } = await verifySession();
11
12 const name = formData.get('name') as string;
13 const price = formData.get('price') as string;
14 const externalUrl = formData.get('externalUrl') as string;
15 const registryDocumentId = formData.get('registryDocumentId') as string;
16 const image = formData.get('image') as File;
17
18 let imageId: number | null = null;
19 if (image && image.size > 0) {
20 const uploadForm = new FormData();
21 uploadForm.append('files', image, image.name);
22
23 const uploadRes = await fetch(`${STRAPI_URL}/api/upload`, {
24 method: 'POST',
25 headers: { Authorization: `Bearer ${token}` },
26 body: uploadForm,
27 });
28 const uploaded = await uploadRes.json();
29 imageId = uploaded?.[0]?.id ?? null;
30 }
31
32 await fetch(`${STRAPI_URL}/api/gift-items`, {
33 method: 'POST',
34 headers: {
35 Authorization: `Bearer ${token}`,
36 'Content-Type': 'application/json',
37 },
38 body: JSON.stringify({
39 data: {
40 name,
41 price: price ? parseFloat(price) : null,
42 externalUrl,
43 quantityDesired: 1,
44 quantityClaimed: 0,
45 image: imageId,
46 registry: { connect: [{ documentId: registryDocumentId }] },
47 },
48 }),
49 });
50
51 revalidatePath('/dashboard');
52}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.
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.
1'use server';
2// app/actions.ts
3
4import { revalidatePath } from 'next/cache';
5
6const STRAPI_URL = process.env.STRAPI_URL;
7
8export async function claimItem(formData: FormData) {
9 const documentId = formData.get('documentId') as string;
10 const slug = formData.get('slug') as string;
11 const claimerName = formData.get('claimerName') as string;
12 const claimerEmail = formData.get('claimerEmail') as string;
13 const message = formData.get('message') as string;
14
15 const res = await fetch(`${STRAPI_URL}/api/gift-items/${documentId}/claim`, {
16 method: 'POST',
17 headers: { 'Content-Type': 'application/json' },
18 body: JSON.stringify({
19 data: { claimerName, claimerEmail, message },
20 }),
21 });
22
23 if (!res.ok) {
24 throw new Error('This item could not be claimed. It may already be taken.');
25 }
26
27 revalidatePath(`/registry/${slug}`);
28}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.
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.
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.
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.