Trip planning content is inherently hierarchical. A single itinerary contains multiple days, each day holds a sequence of activities, and each activity carries a time, location, and category. That kind of structure doesn't fit in a flat blog-style schema. Hardcoding it into the frontend means non-technical collaborators, such as a person who actually researched all those restaurants in Lisbon, can't update anything without a developer's help.
In brief
- What you'll build: A full-stack travel itinerary planner where an index page lists trips, and a detail page renders day-by-day activities, all powered by editable CMS content.
- Backend modeling: Strapi supports components for structuring nested content, though there are documented limitations around repeatable components and deeper nesting in Strapi 5.
- Frontend consumption: Next.js 16 App Router fetches nested itinerary data via Strapi's REST API in Server Components and renders it with dynamic routes.
- Why this pattern matters: The same structure can extend to authentication, map views, and webhook-driven revalidation without changing the core content model.
This tutorial builds a travel itinerary planner with Strapi 5 on the backend and Next.js 16 on the frontend. The Strapi content model uses a Trip Collection Type containing nested Day and Activity components. The Next.js App Router lists trips on an index page and renders a day-by-day detail view using dynamic routes. All data flows through Strapi's REST API, fetched directly in React Server Components.
By the end, you'll have a working full-stack itinerary planner with content that's fully editable from the Strapi Admin Panel. The build takes roughly 30 minutes, and the patterns here are production-extendable (auth, deployment, maps). If you're unfamiliar with a headless CMS, that link covers the fundamentals, but we won't rehash them here.
What You'll Build (and What You'll Need)
The finished app has two views. An index page displays upcoming trips as a grid of cards, each showing a cover image, destination, and date range. Clicking a card opens a detail page with a day-by-day timeline where activities are grouped by time of day, complete with location info and color-coded category badges.
Prerequisites include Node.js, a package manager, and any setup needed for your chosen stack (for example, Python if using SQLite with Strapi).
Prerequisites
- Node.js v20, v22, or v24 (active LTS only; Strapi 5 does not support odd-numbered "current" releases)
- Familiarity with Next.js 16 App Router and React Server Components
- A terminal and a code editor
Architecture at a Glance
The two apps run side by side during development:
- Strapi 5 runs on localhost:1337, exposing REST endpoints for trip data.
- Next.js 16 runs on localhost:3000, fetching that data in Server Components.
- Content model: Trip Collection Type → Day component (repeatable) → Activity component (repeatable)
Set Up the Strapi Backend
Spin up a fresh Strapi project, start the dev server, and create your admin account.
Create the Project
npx create-strapi@latest itinerary-backendThe interactive command-line interface (CLI) prompts you for database and language preferences. Select the default local database option, SQLite, and choose either JavaScript or TypeScript if needed. SQLite works fine for this tutorial, but switch to PostgreSQL or MySQL before going to production. For context on why, see SQLite in production.
The official Strapi 5 quickstart docs mention the interactive CLI steps and suggest accepting the default answers, but they do not walk through each prompt in detail. The CLI also prompts you to log in to Strapi Cloud. Skip that for now with the --skip-cloud flag if you prefer, or just press through the prompts.
Start the Dev Server and Create an Admin User
cd itinerary-backend && npm run developWhen you start the dev server, the Strapi admin panel is available at http://localhost:1337/admin by default. Fill in the registration form to create your first administrator account.
Model the Itinerary Content
A trip is more than a flat record. It has dates, a destination, and a sequence of days, each with its own activities. Strapi can model this kind of structure with Collection Types, reusable components, and relations through its Content-type Builder, so it typically doesn't require custom database work.
Create the Trip Collection Type
Open Content-Type Builder from the left navigation and click the plus icon next to "Collection types." Name the new type Trip and add these fields:
| Field | Type | Notes |
|---|---|---|
| title | Text (short) | Required |
| slug | UID | Attached to title |
| destination | Text (short) | |
| startDate | Date | |
| endDate | Date | |
| description | Rich text (Blocks) | |
| coverImage | Media (single) | Images only |
Save and let Strapi restart.
The slug field uses the UID type, which auto-generates a URL-safe string from the title field and is intended to enforce uniqueness across entries, though duplicates may occur across locales in Strapi 5.
If you want clean, predictable URLs like /trips/lisbon-2025, you'll need custom routing and slug-generation logic rather than relying on Strapi's default REST API behavior. The description field uses Rich text Blocks rather than plain Markdown or HTML. Blocks produce structured JSON output, giving you full control over rendering on the frontend.
Create the Day Component
Navigate to Components in the Content-Type Builder. Create a new component with category itinerary and name day. Add these fields:
| Field | Type | Notes |
|---|---|---|
| dayNumber | Number (integer) | |
| title | Text (short) | |
| summary | Text (long) |
This component lives on disk under the ./src/components/itinerary subfolder (for example as a schema file named after the day component).
Create the Activity Component
Same flow: category itinerary, name activity. Fields:
| Field | Type | Notes |
|---|---|---|
| time | Text (short) | e.g., "09:00" |
| title | Text (short) | |
| location | Text (short) | |
| notes | Text (long) | |
| category | Enumeration | Values: sightseeing, food, transport, accommodation, other |
Nest Activities Inside Days, Days Inside Trips
Now connect the pieces. Edit the Day component and add a new field of type Component. Select itinerary.activity and set it to Repeatable. This lets each day hold multiple activities.
Next, edit the Trip Collection Type. Add a Component field, choose itinerary.day, set it to Repeatable, and name the field days.
The resulting data shape looks like this:
{
"title": "...",
"slug": "...",
"days": [
{
"dayNumber": 1,
"activities": [{ "time": "09:00", "title": "..." }]
}
]
}Strapi 5's centralized Save button lets you work on several content types and components at the same time, so you can batch these edits together before saving.
Two levels of nesting (Trip → Day → Activity) is the sweet spot for this use case. Going deeper makes the populate queries more complex and the Admin Panel harder to navigate. If you find yourself reaching for a third or fourth level, consider whether a separate Collection Type with a relation would be a better fit.
One thing to note: components are not populated by default in API responses. You need to explicitly request nested data using the populate parameter. If you've worked with Dynamic Zones before, those are an alternative for mixed content blocks, but components are the right fit for this structured, predictable hierarchy.
Set Permissions and Add a Sample Trip
Strapi locks down REST endpoints by default. Before the Next.js 16 app can fetch any data, you need to open read access on the Public role. You also need at least one published trip to test against, and "published" is the key word: when the Draft & Publish feature is enabled for a content type in Strapi 5, entries that haven't been explicitly published won't appear in public API responses regardless of your permissions configuration.
Enable Public Read Access
- Go to Settings → Users and Permissions Plugin → Roles → Public
- Expand the Trip permissions section
- Check find and findOne
- Click Save
Any request made without an authentication token now gets read access to trips.
Create One Sample Trip
Open Content Manager, click Trip → Create new entry, and fill in the fields. Add two or three days, each with two or three activities. Upload a cover image.
Publish the entry. This is easy to miss: Draft and Publish is enabled by default in Strapi 5, and drafts won't appear in public API responses regardless of your permissions configuration.
Verify by hitting http://localhost:1337/api/trips in the browser. You should see your trip data in the response.
Set Up the Next.js 16 Frontend
Spin up a Next.js 16 project alongside the backend and configure the API base URL.
Create the Next.js 16 Project
npx create-next-app@latest itinerary-frontend --typescript --app --tailwindThis scaffolds a TypeScript project with the App Router and Tailwind CSS. Confirm the defaults for the remaining prompts.
Configure Environment Variables
Create .env.local in the project root:
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337A public environment variable is fine here since this is read-only public data. For authenticated endpoints, you'd use a server-only variable (no NEXT_PUBLIC_ prefix) and pass a Bearer token.
Define TypeScript Types for the Response
Create lib/types.ts. These interfaces match the Strapi 5 response shape, where fields sit directly on the data object with no attributes wrapper. Components carry only a numeric id, not a documentId. For a full breakdown of how Strapi 5 shapes its REST responses, see REST response structure.
// lib/types.ts
export interface StrapiDocument {
id: number;
documentId: string;
createdAt: string;
updatedAt: string;
publishedAt: string | null;
locale: string | null;
}
export interface StrapiMediaFormat {
name: string;
hash: string;
ext: string;
mime: string;
width: number;
height: number;
size: number;
url: string;
}
export interface StrapiMedia extends StrapiDocument {
name: string;
alternativeText: string | null;
caption: string | null;
width: number | null;
height: number | null;
formats: {
thumbnail?: StrapiMediaFormat; // 156px
small?: StrapiMediaFormat; // 500px
medium?: StrapiMediaFormat; // 750px
large?: StrapiMediaFormat; // 1000px
} | null;
hash: string;
ext: string;
mime: string;
size: number;
url: string;
previewUrl: string | null;
provider: string;
}
export interface Activity {
id: number;
time: string;
title: string;
location: string;
notes: string | null;
category: "sightseeing" | "food" | "transport" | "accommodation" | "other";
}
export interface Day {
id: number;
dayNumber: number;
title: string;
summary: string | null;
activities: Activity[];
}
export interface Trip {
id: number;
documentId: string;
title: string;
slug: string;
destination: string;
startDate: string;
endDate: string;
description: unknown[] | null;
coverImage: StrapiMedia | null;
days: Day[];
}
export interface TripListResponse {
data: Trip[];
meta: { pagination: { page: number; pageSize: number; pageCount: number; total: number } };
}
// getTripBySlug uses a filtered collection query, so the response is still an array
export interface TripSingleResponse {
data: Trip[];
meta: Record<string, unknown>;
}For larger projects, consider generating these types from Strapi's OpenAPI spec. The type-safe fetching guide covers that approach in detail.
Build the Trips List Page
Fetch trips on the server in a Server Component, render them as a grid of cards, and let Next.js 16 handle caching.
Create the API Helper
// lib/api.ts
import { TripListResponse, TripSingleResponse } from "./types";
const BASE_URL = process.env.NEXT_PUBLIC_STRAPI_URL ?? "http://localhost:1337";
async function fetchStrapi<T>(path: string): Promise<T> {
const res = await fetch(`${BASE_URL}/api${path}`, {
headers: { "Content-Type": "application/json" },
next: { revalidate: 60 },
});
if (!res.ok) throw new Error(`Strapi fetch failed: ${res.status}`);
return res.json() as Promise<T>;
}
export async function getTrips(): Promise<TripListResponse> {
return fetchStrapi<TripListResponse>("/trips?populate=coverImage");
}
export async function getTripBySlug(slug: string): Promise<TripSingleResponse> {
return fetchStrapi<TripSingleResponse>(
`/trips?filters[slug][$eq]=${slug}&populate[coverImage]=true&populate[days][populate][activities]=true`
);
}Notice that getTrips only populates coverImage. The list view doesn't need days or activities, and pulling them in would bloat the response unnecessarily. On a collection with thousands of entries, populate=* can make responses much heavier and slower.
In Strapi, no population is the correct default, and explicit/precise population should be used as needed rather than relying on populate=*. See fetch patterns for more fetch patterns.
As an alternative to raw fetch, Strapi SDK provides a client library for interacting with your Strapi back end.
Build the Trips Page
// app/trips/page.tsx
import Image from "next/image";
import Link from "next/link";
import { getTrips } from "@/lib/api";
function formatDateRange(start: string, end: string) {
const fmt = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric" });
return `${fmt.format(new Date(start))} – ${fmt.format(new Date(end))}`;
}
export default async function TripsPage() {
const { data: trips } = await getTrips();
return (
<main className="max-w-5xl mx-auto px-4 py-12">
<h1 className="text-3xl font-bold mb-8">Upcoming Trips</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{trips.map((trip) => (
<Link key={trip.documentId} href={`/trips/${trip.slug}`} className="group">
<div className="rounded-lg overflow-hidden border">
{trip.coverImage && (
<Image
src={`${process.env.NEXT_PUBLIC_STRAPI_URL}${trip.coverImage.url}`}
alt={trip.coverImage.alternativeText ?? trip.title}
width={600}
height={400}
className="w-full h-48 object-cover"
/>
)}
<div className="p-4">
<h2 className="font-semibold text-lg group-hover:underline">{trip.title}</h2>
<p className="text-sm text-gray-500">{trip.destination}</p>
<p className="text-sm text-gray-400 mt-1">
{formatDateRange(trip.startDate, trip.endDate)}
</p>
</div>
</div>
</Link>
))}
</div>
</main>
);
}The next: { revalidate: 60 } option in the fetch helper tells Next.js 16 to cache the response for 60 seconds and revalidate it on a timed interval. Here's how the stale-while-revalidate cycle works: the first request after the 60-second window still receives the cached version instantly, but it triggers a background regeneration.
The request that triggers regeneration still receives the stale cached page; the following request gets the freshly built page once regeneration has completed successfully. For a travel planner where content updates are infrequent, you could increase this to 3600 (one hour) or even higher. For real-time updates, consider on-demand revalidation triggered by Strapi webhooks instead of a fixed interval.
Format Dates Without a Date Library
The formatDateRange helper above uses Intl.DateTimeFormat, which is built into every modern runtime. No need to pull in moment.js or date-fns for this.
The next/image docs explain how to allow images from external hosts. That configuration comes in a later section.
Build the Trip Detail Page with Nested Days and Activities
The detail page is where the nested model pays off. One request fetches a trip plus every day and activity inside it, and the App Router handles the dynamic segment.
Set Up the Dynamic Route
Create the file app/trips/[slug]/page.tsx. Next.js params need to be awaited before destructuring in Next.js 16.
// app/trips/[slug]/page.tsx
import { notFound } from "next/navigation";
import Image from "next/image";
import { getTripBySlug, getTrips } from "@/lib/api";
import { Activity } from "@/lib/types";
import { categoryClass } from "@/lib/categories";
export async function generateStaticParams() {
const { data: trips } = await getTrips();
return trips.map((trip) => ({ slug: trip.slug }));
}
export default async function TripDetailPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const { data: trips } = await getTripBySlug(slug);
const trip = trips[0];
if (!trip) notFound();
// render below...
}The generateStaticParams function runs at build time and pre-renders a page for each known slug. New trips added after the build are handled by Next.js 16's fallback behavior, which renders them on first request and then caches the result.
Fetch One Trip with All Nested Content
The getTripBySlug function (defined earlier in lib/api.ts) uses filter + deep populate:
/api/trips?filters[slug][$eq]=lisbon-2025&populate[coverImage]=true&populate[days][populate][activities]=trueThat bracket notation tells Strapi to go two levels deep: populate days on the trip, then populate activities on each day. This is fine for a single record, but never use this depth as the default for list endpoints. For more on the populate object syntax, see populate and filtering.
Render the Day-by-Day Timeline
Continue the component from above:
return (
<main className="max-w-3xl mx-auto px-4 py-12">
{trip.coverImage && (
<Image
src={`${process.env.NEXT_PUBLIC_STRAPI_URL}${trip.coverImage.url}`}
alt={trip.coverImage.alternativeText ?? trip.title}
width={1200}
height={600}
className="w-full rounded-lg mb-8 object-cover"
/>
)}
<h1 className="text-3xl font-bold">{trip.title}</h1>
<p className="text-gray-500 mb-8">{trip.destination}</p>
<div className="space-y-10">
{trip.days
.sort((a, b) => a.dayNumber - b.dayNumber)
.map((day) => (
<section key={day.id}>
<h2 className="text-xl font-semibold mb-1">
Day {day.dayNumber}: {day.title}
</h2>
{day.summary && <p className="text-gray-500 mb-4">{day.summary}</p>}
<ul className="space-y-3">
{day.activities
.sort((a, b) => a.time.localeCompare(b.time))
.map((activity: Activity) => (
<li key={activity.id} className="flex items-start gap-3 p-3 border rounded-md">
<span className="text-sm font-mono text-gray-400 pt-0.5">
{activity.time}
</span>
<div>
<p className="font-medium">{activity.title}</p>
<p className="text-sm text-gray-500">{activity.location}</p>
<span className={`text-xs px-2 py-0.5 rounded-full ${categoryClass(activity.category)}`}>
{activity.category}
</span>
{activity.notes && (
<p className="text-sm text-gray-400 mt-1">{activity.notes}</p>
)}
</div>
</li>
))}
</ul>
</section>
))}
</div>
</main>
);Handle the Not-Found Case
If getTripBySlug returns an empty array, calling notFound() terminates rendering and will typically return a 404 status for non-streamed responses, but may return 200 for streamed responses when using a not-found file. This is preferable to rendering an empty state, which would send a 200 to search engines for a URL with no content.
For sites with frequently updated content, use ISR so new trips can appear without a full redeploy.
Polish the UI with Images and Category Badges
Two small additions finish the UI: letting Next.js 16 optimize images from Strapi, and mapping each activity category to a colored badge. Travel content tends to be image-heavy, with large cover photos and destination shots that benefit from automatic format conversion (WebP/AVIF) and responsive sizing. Without proper configuration, those images either fail to load or ship at full resolution to every device.
Configure next/image for the Strapi Media URL
Update next.config.ts to allow images from your Strapi server:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
dangerouslyAllowLocalIP: true,
remotePatterns: [
{
protocol: "http",
hostname: "localhost",
port: "1337",
pathname: "/uploads/**",
},
],
},
};
export default nextConfig;The dangerouslyAllowLocalIP flag is an optional setting for rare private-network cases, not required for local development. The image configuration explains how to allow next/image to optimize locally served images during development. Remove it in production and use your actual media domain instead.
In production, replace the remotePatterns entry with your actual media provider domain (S3, Cloudinary, or Strapi Cloud's content delivery network (CDN)). For a deeper look at image-related pitfalls, see performance mistakes. Missing remotePatterns config can cause 400 errors for remote images and prevent Next.js 16 from applying its image optimization pipeline, including format conversion that may reduce image size.
Color-Code Activity Categories
Map the category enum to Tailwind classes with a small helper:
// lib/categories.ts
const CATEGORY_CLASSES: Record<string, string> = {
sightseeing: "bg-blue-100 text-blue-800",
food: "bg-orange-100 text-orange-800",
transport: "bg-gray-100 text-gray-800",
accommodation: "bg-purple-100 text-purple-800",
other: "bg-green-100 text-green-800",
};
export function categoryClass(category: string): string {
return CATEGORY_CLASSES[category] ?? CATEGORY_CLASSES.other;
}Import this into your detail page to render a colored pill on each activity card. The detail page code above already references categoryClass.
Deploy and What to Build Next
Two deployment paths, a few common gotchas, and three feature ideas worth picking up next.
Where to Deploy Each Side
Strapi: Strapi Cloud is the managed option and handles PostgreSQL, media storage, and scaling for you. Self-hosting on a VPS, Render, or Railway works too. Switch SQLite to PostgreSQL before deploying. The deployment options guide covers the tradeoffs for each platform.
Next.js: Vercel is the path of least resistance and works with the App Router out of the box. Set your NEXT_PUBLIC_STRAPI_URL environment variable in the Vercel dashboard pointing at your production Strapi instance (e.g., https://api.yourdomain.com). Also update the remotePatterns in next.config.ts to match your production media host, and remove dangerouslyAllowLocalIP since it's only needed for local development.
Common Issues
- Empty API response: You forgot to publish the entry. In Strapi 5, Draft & Publish can be configured per content type in the Content-type Builder. By default, Strapi 5 REST and GraphQL Content API responses return the published version when no status parameter is passed.
- Nested data missing from response: You didn't use the populate parameter. Components are not included by default. Use bracket notation like populate[days][populate][activities]=true.
- Images return 400 errors: Images can return 400 errors if your next.config.ts is missing the remotePatterns entry for your Strapi server during development.
- Next.js 16 params fail: In the App Router, params is now a Promise. You must await it before accessing properties like slug.
Three Extensions
- Auth: Gate trip editing behind Strapi's Users and Permissions plugin so each user manages their own trips. You can tailor API access per user with custom policies and related backend logic, turning the planner from a read-only display into a personal trip organizer. The Next.js 16 auth tutorial walks through login and registration flows with Strapi 5.
- Map view: Add lat and lng number fields to the Activity component and plot each activity on a Mapbox or Leaflet map in the detail view. Both libraries offer free tiers that work well for development and low-traffic production use.
- Webhooks: Configure a Strapi webhook to trigger ISR revalidation on publish. When an editor publishes or updates a trip in the Admin Panel, Strapi fires a POST request to your Next.js 16 revalidation endpoint, which rebuilds only the affected page. Updates appear on the frontend without a redeploy, and the content stays static between changes. The webhooks guide walks through the configuration.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.