✨ Strapi MCP is now Generally Available - let your agents manage your Strapi content ✨

Ecosystem15 min read

How to Build Your Personal Library and Book Catalog System Using Strapi 5

July 29, 2026
How to Build Your Personal Library and Book Catalog System Using Strapi 5

Tracking a personal book collection gets messy fast. Spreadsheets lose context, and most off-the-shelf apps don't let you structure data the way you think about your shelves. A headless CMS gives you full control over your data model while exposing a ready-made API for any frontend you want.

This tutorial walks through building a personal library catalog powered by Strapi 5. You'll define three Collection Types (Book, Author, Category), wire up relations between them, add a reusable component for physical book details, seed sample data, and consume everything from a vanilla HTML/JavaScript frontend. The entire project runs locally with SQLite, so there's no external database to configure.

In brief:

  • Defining Collection Types, components, and relations (one-to-many, many-to-many) with Strapi v5 schema files
  • Seeding data with the Document Service API in the bootstrap() lifecycle hook
  • Setting up public permissions and API tokens for secure API access
  • Consuming the Strapi v5 REST API from a frontend with pagination and populated relations

Prerequisites

Before starting, make sure you have the following installed and ready:

  • Node.js v24.x LTS (v24.16.0 "Krypton" or later). Install via nvm: nvm install 24 && nvm use 24
  • npm v11 (ships with Node.js 24)
  • Strapi v5.47.0 (installed during project creation)
  • A code editor (VS Code, WebStorm, or similar)
  • A terminal for running CLI commands
  • Basic familiarity with JavaScript, REST APIs, and JSON

No external accounts or API keys are needed. The project uses SQLite (bundled) and the local file upload provider.

Setting Up the Strapi Backend

Step 1: Create a New Strapi Project

Open your terminal and run the Strapi project creation command. The flags skip the cloud login prompt, select TypeScript, and default to SQLite:

npx create-strapi@latest my-library-catalog --skip-cloud --ts --skip-db

Once installation finishes, move into the project directory:

cd my-library-catalog

Build and start the development server:

npm run build
npm run develop

Strapi launches at http://localhost:1337/admin. Create your first admin account through the registration form. Keep this terminal running throughout the tutorial.

Step 2: Define the Author Content-Type

Stop the dev server (Ctrl+C). You'll create Content-Type schemas as JSON files directly in the src/api directory. Strapi picks these up on the next server start. For a deeper look at content modeling patterns, that guide covers the principles.

Create the folder structure and schema for Authors:

mkdir -p src/api/author/content-types/author
mkdir -p src/api/author/controllers
mkdir -p src/api/author/routes
mkdir -p src/api/author/services

Add the schema file:

// src/api/author/content-types/author/schema.json
{
  "kind": "collectionType",
  "collectionName": "authors",
  "info": {
    "singularName": "author",
    "pluralName": "authors",
    "displayName": "Author"
  },
  "attributes": {
    "name": {
      "type": "string",
      "required": true
    },
    "bio": {
      "type": "text"
    },
    "books": {
      "type": "relation",
      "relation": "oneToMany",
      "target": "api::book.book",
      "mappedBy": "author"
    }
  }
}

The books field uses mappedBy because this is the inverse side of the relation. The Book Content-Type (created next) owns the relationship. For more on how relations work, that guide covers the details.

Add the default controller, route, and service files so Strapi registers the API endpoints:

// src/api/author/controllers/author.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreController('api::author.author');
// src/api/author/routes/author.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreRouter('api::author.author');
// src/api/author/services/author.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreService('api::author.author');

Step 3: Define the Category Content-Type

Create the directory structure:

mkdir -p src/api/category/content-types/category
mkdir -p src/api/category/controllers
mkdir -p src/api/category/routes
mkdir -p src/api/category/services
// src/api/category/content-types/category/schema.json
{
  "kind": "collectionType",
  "collectionName": "categories",
  "info": {
    "singularName": "category",
    "pluralName": "categories",
    "displayName": "Category"
  },
  "attributes": {
    "name": {
      "type": "string",
      "required": true,
      "unique": true
    },
    "books": {
      "type": "relation",
      "relation": "manyToMany",
      "target": "api::book.book",
      "mappedBy": "categories"
    }
  }
}

The Category-to-Book relationship is many-to-many: one book can belong to multiple categories, and one category can contain multiple books. mappedBy marks this as the inverse side.

// src/api/category/controllers/category.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreController('api::category.category');
// src/api/category/routes/category.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreRouter('api::category.category');
// src/api/category/services/category.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreService('api::category.category');

Step 4: Define the Physical Details Component

Components in Strapi must live inside a category subfolder under src/components. Create the folder and schema:

mkdir -p src/components/book
// src/components/book/physical-details.json
{
  "collectionName": "components_book_physical_details",
  "info": {
    "displayName": "Physical Details"
  },
  "attributes": {
    "pageCount": {
      "type": "integer"
    },
    "format": {
      "type": "enumeration",
      "enum": ["hardcover", "paperback", "ebook"]
    }
  }
}

This component holds metadata about the physical format of a book. The enumeration field restricts format to three valid options.

Step 5: Define the Book Content-Type

Create the directory structure:

mkdir -p src/api/book/content-types/book
mkdir -p src/api/book/controllers
mkdir -p src/api/book/routes
mkdir -p src/api/book/services
// src/api/book/content-types/book/schema.json
{
  "kind": "collectionType",
  "collectionName": "books",
  "info": {
    "singularName": "book",
    "pluralName": "books",
    "displayName": "Book"
  },
  "options": {
    "draftAndPublish": true
  },
  "attributes": {
    "title": {
      "type": "string",
      "required": true
    },
    "isbn": {
      "type": "uid",
      "targetField": "title"
    },
    "description": {
      "type": "richtext"
    },
    "publishedYear": {
      "type": "integer"
    },
    "coverImage": {
      "type": "media",
      "allowedTypes": ["images"],
      "multiple": false
    },
    "author": {
      "type": "relation",
      "relation": "manyToOne",
      "target": "api::author.author",
      "inversedBy": "books"
    },
    "categories": {
      "type": "relation",
      "relation": "manyToMany",
      "target": "api::category.category",
      "inversedBy": "books"
    },
    "physicalDetails": {
      "type": "component",
      "repeatable": false,
      "component": "book.physical-details"
    }
  }
}

The Book schema is the core of the catalog. Key details:

  • author uses manyToOne with inversedBy, making Book the owning side of the Author relation
  • categories uses manyToMany with inversedBy, making Book the owning side of the Category relation
  • coverImage accepts only image files (the local Media Library provider stores them in public/uploads/)
  • draftAndPublish: true enables the Draft and Publish workflow, so books need to be explicitly published before the REST API returns them
// src/api/book/controllers/book.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreController('api::book.book');
// src/api/book/routes/book.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreRouter('api::book.book');
// src/api/book/services/book.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreService('api::book.book');

Step 6: Add a Custom Search Route and Controller

The default CRUD endpoints cover listing and fetching individual books. A search endpoint that queries across title and description fields gives the frontend a better experience.

Create a custom route file. Prefix the filename with 01- so it loads before the core book.ts router:

// src/api/book/routes/01-custom-book.ts
export default {
  routes: [
    {
      method: 'GET',
      path: '/books/search',
      handler: 'api::book.book.search',
      config: {
        policies: [],
        middlewares: [],
      },
    },
  ],
};

Replace the default controller with one that includes both the core CRUD and the new search action. The Document Service API replaces the deprecated v4 Entity Service:

// src/api/book/controllers/book.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreController('api::book.book', ({ strapi }) => ({
  async search(ctx) {
    const { q = '' } = ctx.query;

    const books = await strapi.documents('api::book.book').findMany({
      status: 'published',
      filters: {
        $or: [
          { title: { $containsi: q } },
          { description: { $containsi: q } },
        ],
      },
      populate: {
        author: { fields: ['name'] },
        coverImage: { fields: ['url', 'name', 'alternativeText'] },
        categories: { fields: ['name'] },
      },
      fields: ['title', 'isbn', 'publishedYear', 'description'],
      sort: 'title:asc',
    });

    return { data: books };
  },
}));

Note the explicit status: 'published' parameter. In Strapi's Document Service API, custom controller queries return the draft version by default when status is omitted. Always specify the status to avoid returning unpublished content to the frontend.

Step 7: Seed Sample Data

The bootstrap() function in src/index.ts runs on every server start. Use it to seed initial catalog data with an idempotency check so records aren't duplicated on restart.

// src/index.ts
import type { Core } from '@strapi/strapi';

export default {
  register({ strapi }: { strapi: Core.Strapi }) {},

  async bootstrap({ strapi }: { strapi: Core.Strapi }) {
    const existingBooks = await strapi.documents('api::book.book').findMany({
      limit: 1,
    });

    if (existingBooks.length > 0) {
      strapi.log.info('Books already exist — skipping seed.');
      return;
    }

    strapi.log.info('Seeding initial book catalog data...');

    const fantasy = await strapi.documents('api::category.category').create({
      data: { name: 'Fantasy' },
    });

    const sciFi = await strapi.documents('api::category.category').create({
      data: { name: 'Science Fiction' },
    });

    const classics = await strapi.documents('api::category.category').create({
      data: { name: 'Classics' },
    });

    const tolkien = await strapi.documents('api::author.author').create({
      data: {
        name: 'J.R.R. Tolkien',
        bio: 'English author, poet, philologist, and academic.',
      },
    });

    const asimov = await strapi.documents('api::author.author').create({
      data: {
        name: 'Isaac Asimov',
        bio: 'American writer and professor of biochemistry.',
      },
    });

    const hobbit = await strapi.documents('api::book.book').create({
      data: {
        title: 'The Hobbit',
        isbn: '978-0-261-10221-7',
        publishedYear: 1937,
        description: 'A fantasy novel about the adventures of Bilbo Baggins.',
        author: tolkien.documentId,
        categories: {
          connect: [fantasy.documentId, classics.documentId],
        },
        physicalDetails: {
          pageCount: 310,
          format: 'paperback',
        },
      },
    });

    const fellowship = await strapi.documents('api::book.book').create({
      data: {
        title: 'The Fellowship of the Ring',
        isbn: '978-0-261-10235-4',
        publishedYear: 1954,
        description: 'First volume of The Lord of the Rings.',
        author: tolkien.documentId,
        categories: {
          connect: [fantasy.documentId],
        },
        physicalDetails: {
          pageCount: 423,
          format: 'hardcover',
        },
      },
    });

    const foundation = await strapi.documents('api::book.book').create({
      data: {
        title: 'Foundation',
        isbn: '978-0-553-29335-7',
        publishedYear: 1951,
        description: 'The first novel in the Foundation series.',
        author: asimov.documentId,
        categories: {
          connect: [sciFi.documentId, classics.documentId],
        },
        physicalDetails: {
          pageCount: 244,
          format: 'paperback',
        },
      },
    });

    await strapi.documents('api::book.book').publish({ documentId: hobbit.documentId });
    await strapi.documents('api::book.book').publish({ documentId: fellowship.documentId });
    await strapi.documents('api::book.book').publish({ documentId: foundation.documentId });

    strapi.log.info('Book catalog seeded and published successfully.');
  },

  destroy({ strapi }: { strapi: Core.Strapi }) {},
};

After creating each book, the publish() method moves it from draft to published. By default, Strapi's REST API treats published content as the default status.

Step 8: Configure Permissions and API Tokens

Restart the server to pick up the new schemas and run startup logic such as migrations or seed/bootstrap code:

npm run develop

You should see "Seeding initial book catalog data..." and "Book catalog seeded and published successfully." in your terminal output.

Set public permissions so the frontend can read data without authentication:

  1. Open the Admin Panel at http://localhost:1337/admin
  2. Navigate to Settings > Users and Permissions plugin > Roles > Public
  3. Under Book, enable find, findOne, and search
  4. Under Author, enable find and findOne
  5. Under Category, enable find and findOne
  6. Click Save

If you need write access from the frontend (creating or updating books), create an API token instead:

  1. Navigate to Settings > Global settings > API Tokens
  2. Click Create new API Token
  3. Set Name to "Library Frontend"
  4. Set Token type to "Full access" (or "Custom" for granular control)
  5. Set Token duration to "Unlimited"
  6. Click Save and copy the token immediately

With public find/findOne enabled, read requests work without a token. If the relevant read permissions are granted to the Public role, a token may only be needed for protected operations such as create, update, and delete.

Step 9: Verify the REST API

Test the endpoints with curl to confirm everything works. The REST API in Strapi v5 uses documentId (a string) as the primary identifier, not the numeric id from v4. For details on how populate and filtering work, that guide covers the patterns:

curl "http://localhost:1337/api/books?populate=author&populate=coverImage&populate=categories&populate=physicalDetails" | jq

The response uses Strapi v5's flat format. Fields sit directly on each object, with no data.attributes nesting:

{
  "data": [
    {
      "id": 1,
      "documentId": "abc123def456",
      "title": "The Hobbit",
      "isbn": "978-0-261-10221-7",
      "publishedYear": 1937,
      "description": "A fantasy novel about the adventures of Bilbo Baggins.",
      "createdAt": "2026-06-01T10:00:00.000Z",
      "updatedAt": "2026-06-01T10:00:00.000Z",
      "publishedAt": "2026-06-01T10:00:00.000Z",
      "author": {
        "id": 1,
        "documentId": "xyz789ghi012",
        "name": "J.R.R. Tolkien"
      },
      "categories": [
        { "id": 1, "documentId": "cat001", "name": "Fantasy" },
        { "id": 3, "documentId": "cat003", "name": "Classics" }
      ],
      "physicalDetails": {
        "pageCount": 310,
        "format": "paperback"
      }
    }
  ],
  "meta": {
    "pagination": { "page": 1, "pageSize": 25, "pageCount": 1, "total": 3 }
  }
}

Relations, media fields, components, and Dynamic Zones are never returned unless explicitly populated. A bare GET /api/books returns only scalar fields.

Test the custom search endpoint:

curl "http://localhost:1337/api/books/search?q=ring" | jq

Building the Vanilla JavaScript Frontend

The frontend uses HTML, CSS, and vanilla JavaScript without a framework. This keeps the focus on API consumption patterns that apply regardless of your preferred framework. For production applications, consider swapping the vanilla JS client for a React or Next.js app.

Step 1: Create the Frontend File

Create a frontend directory at the project root and add an index.html file:

mkdir frontend
<!-- frontend/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My Library Catalog</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: system-ui, -apple-system, sans-serif; max-width: 1100px; margin: 0 auto; padding: 20px; color: #1a1a1a; }
    h1 { margin-bottom: 20px; }
    .controls { display: flex; gap: 10px; margin-bottom: 24px; flex-wrap: wrap; }
    .controls input { flex: 1; min-width: 200px; padding: 10px 14px; border: 1px solid #ccc; border-radius: 6px; font-size: 1rem; }
    .controls button { padding: 10px 20px; border: none; border-radius: 6px; background: #4945ff; color: #fff; font-size: 1rem; cursor: pointer; }
    .controls button:hover { background: #3b38e0; }
    .book-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 20px; }
    .book-card { border: 1px solid #e0e0e0; border-radius: 8px; padding: 16px; transition: box-shadow 0.2s; }
    .book-card:hover { box-shadow: 0 2px 12px rgba(0,0,0,0.1); }
    .book-card img { width: 100%; height: 200px; object-fit: cover; border-radius: 4px; background: #f0f0f0; }
    .book-title { font-weight: 700; font-size: 1.05rem; margin: 10px 0 4px; }
    .book-author { color: #555; font-size: 0.9rem; }
    .book-meta { font-size: 0.82rem; color: #888; margin-top: 6px; }
    .book-categories { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
    .category-tag { background: #f0f0ff; color: #4945ff; padding: 2px 8px; border-radius: 4px; font-size: 0.78rem; }
    .pagination { margin-top: 24px; display: flex; gap: 10px; align-items: center; }
    .pagination button { padding: 8px 16px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer; }
    .pagination button:disabled { opacity: 0.4; cursor: default; }
    .empty { padding: 40px; text-align: center; color: #888; }
    .error { padding: 20px; background: #fff0f0; border: 1px solid #ffcccc; border-radius: 6px; color: #cc0000; }
  </style>
</head>
<body>
  <h1>My Library Catalog</h1>

  <div class="controls">
    <input type="text" id="searchInput" placeholder="Search by title or description..." />
    <button id="searchBtn">Search</button>
    <button id="resetBtn">Show All</button>
  </div>

  <div id="bookGrid" class="book-grid"></div>
  <div id="pagination" class="pagination"></div>

  <script>
    const STRAPI_URL = 'http://localhost:1337';
    const PAGE_SIZE = 6;

    let currentPage = 1;
    let currentSearch = '';

    async function fetchBooks(page, search) {
      const params = new URLSearchParams();
      params.set('pagination[page]', String(page));
      params.set('pagination[pageSize]', String(PAGE_SIZE));
      params.set('sort', 'title:asc');
      params.set('populate[0]', 'author');
      params.set('populate[1]', 'coverImage');
      params.set('populate[2]', 'categories');
      params.set('populate[3]', 'physicalDetails');
      params.set('fields[0]', 'title');
      params.set('fields[1]', 'isbn');
      params.set('fields[2]', 'publishedYear');

      if (search) {
        params.set('filters[$or][0][title][$containsi]', search);
        params.set('filters[$or][1][description][$containsi]', search);
      }

      const response = await fetch(STRAPI_URL + '/api/books?' + params.toString());

      if (!response.ok) {
        throw new Error('Failed to fetch books: ' + response.status);
      }

      return response.json();
    }

    function imageUrl(media) {
      if (!media || !media.url) return '';
      return STRAPI_URL + media.url;
    }

    function renderBooks(books) {
      const grid = document.getElementById('bookGrid');

      if (books.length === 0) {
        grid.innerHTML = '<div class="empty">No books found.</div>';
        return;
      }

      grid.innerHTML = books.map(function(book) {
        const cover = book.coverImage
          ? '<img src="' + imageUrl(book.coverImage) + '" alt="' + book.title + '" loading="lazy" />'
          : '<img src="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27200%27 height=%27280%27%3E%3Crect fill=%27%23eee%27 width=%27200%27 height=%27280%27/%3E%3Ctext x=%2750%25%27 y=%2750%25%27 text-anchor=%27middle%27 fill=%27%23999%27 font-size=%2714%27%3ENo Cover%3C/text%3E%3C/svg%3E" alt="No cover" />';

        const authorName = book.author ? book.author.name : 'Unknown Author';

        var categoryTags = '';
        if (book.categories && book.categories.length > 0) {
          categoryTags = '<div class="book-categories">' +
            book.categories.map(function(c) {
              return '<span class="category-tag">' + c.name + '</span>';
            }).join('') +
            '</div>';
        }

        var metaParts = [];
        if (book.publishedYear) metaParts.push(String(book.publishedYear));
        if (book.physicalDetails && book.physicalDetails.format) {
          metaParts.push(book.physicalDetails.format);
        }
        if (book.physicalDetails && book.physicalDetails.pageCount) {
          metaParts.push(book.physicalDetails.pageCount + ' pages');
        }
        var metaLine = metaParts.length > 0
          ? '<div class="book-meta">' + metaParts.join(' · ') + '</div>'
          : '';

        return '<div class="book-card">' +
          cover +
          '<div class="book-title">' + book.title + '</div>' +
          '<div class="book-author">by ' + authorName + '</div>' +
          metaLine +
          categoryTags +
        '</div>';
      }).join('');
    }

    function renderPagination(meta) {
      var p = meta.pagination;
      var container = document.getElementById('pagination');
      container.innerHTML =
        '<button id="prevBtn"' + (p.page <= 1 ? ' disabled' : '') + '>Previous</button>' +
        '<span>Page ' + p.page + ' of ' + p.pageCount + ' (' + p.total + ' books)</span>' +
        '<button id="nextBtn"' + (p.page >= p.pageCount ? ' disabled' : '') + '>Next</button>';

      document.getElementById('prevBtn').addEventListener('click', function() {
        loadPage(currentPage - 1);
      });
      document.getElementById('nextBtn').addEventListener('click', function() {
        loadPage(currentPage + 1);
      });
    }

    async function loadPage(page) {
      currentPage = page;
      var grid = document.getElementById('bookGrid');

      try {
        var result = await fetchBooks(page, currentSearch);
        renderBooks(result.data);
        renderPagination(result.meta);
      } catch (err) {
        grid.innerHTML = '<div class="error">Error loading books: ' + err.message + '</div>';
      }
    }

    document.getElementById('searchBtn').addEventListener('click', function() {
      currentSearch = document.getElementById('searchInput').value.trim();
      loadPage(1);
    });

    document.getElementById('resetBtn').addEventListener('click', function() {
      currentSearch = '';
      document.getElementById('searchInput').value = '';
      loadPage(1);
    });

    document.getElementById('searchInput').addEventListener('keypress', function(e) {
      if (e.key === 'Enter') {
        currentSearch = e.target.value.trim();
        loadPage(1);
      }
    });

    loadPage(1);
  </script>
</body>
</html>

Step 2: Handle CORS for Local Development

Strapi needs to allow requests from your frontend origin. The default middleware configuration includes CORS, but you may need to adjust it if the frontend runs on a different port.

Open config/middlewares.ts and confirm strapi::cors is present. For local file access (file://) or a different dev server, replace the string entry with an explicit configuration:

// config/middlewares.ts
export default [
  'strapi::logger',
  'strapi::errors',
  'strapi::security',
  {
    name: 'strapi::cors',
    config: {
      origin: ['http://localhost:1337', 'http://localhost:5500', 'http://127.0.0.1:5500'],
      methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
      headers: ['Content-Type', 'Authorization'],
    },
  },
  'strapi::poweredBy',
  'strapi::query',
  'strapi::body',
  'strapi::session',
  'strapi::favicon',
  'strapi::public',
];

Add your frontend's origin URL to the origin array. If you're using VS Code's Live Server extension, it typically runs on port 5500.

Putting It All Together

Rebuild Strapi to pick up the middleware changes, then start the server:

npm run build
npm run develop

Open frontend/index.html in a browser (or serve it with any static file server on a port listed in your CORS config). You should see:

  • A grid of three book cards: "Foundation," "The Fellowship of the Ring," and "The Hobbit"
  • Each card displays key book details
  • Tags are managed through a Category Collection Type and rendered as tag elements
  • The search bar filters results. Typing "ring" and pressing Enter returns only "The Fellowship of the Ring." Clicking "Show All" resets the view.
  • Pagination controls appear below the grid

Verify the books endpoint independently:

curl "http://localhost:1337/api/books" | jq

Next Steps

  • Deploy to production. The Strapi deployment guide covers hosting options. Swap SQLite for PostgreSQL in config/database.ts before deploying. The deployment walkthrough covers common platforms.
  • Add cover images. Upload book covers through the Admin Panel's Media Library or programmatically via the upload REST endpoint. The frontend already handles coverImage population.
  • Build a richer frontend. Replace the vanilla HTML with a framework like Next.js or React for routing, server-side rendering, and component reuse. Strapi's REST API works identically regardless of the consuming framework.
  • Add user authentication. The Users and Permissions plugin supports end-user registration and JWT-based login. Authenticated users could maintain personal reading lists or mark books as read.
  • Use the official client library. The @strapi/client package provides typed collection methods (find, findOne, create, update, delete) that simplify frontend API calls. For TypeScript benefits in your frontend, that guide covers the advantages.

Related Posts

Build a Travel Itinerary Planner with Strapi & Next.js
Ecosystem·16 min read

How to Build a Travel Itinerary Planner with Strapi and Next.js

Learn how to build a travel itinerary planner with Strapi 5 and Next.js 16. Model nested content, fetch via REST API, and render dynamic trip pages.

·May 28, 2026