Skip to content

Next.js 13 + Strapi CMS (backend)

Strapi y Next.js 13 son dos herramientas poderosas que se complementan perfectamente para desarrollar aplicaciones web modernas y escalables. Strapi es un CMS (Sistema de Gestión de Contenidos) de código abierto que permite crear y administrar fácilmente contenido estructurado, mientras que Next.js es un framework de React que facilita la construcción de aplicaciones web con enfoque en el rendimiento y la experiencia del usuario.

Con Strapi, puedes diseñar y gestionar tu contenido de manera intuitiva, estableciendo modelos de datos personalizados y creando API RESTful o GraphQL para acceder a esos datos. Next.js, por su parte, te brinda un entorno de desarrollo optimizado y predecible, permitiéndote construir aplicaciones web con representación del lado del servidor (SSR) y generación de páginas estáticas (SSG), lo que mejora el rendimiento y la velocidad de carga de tus páginas.

La combinación de Strapi y Next.js te permite crear aplicaciones web dinámicas, ricas en contenido y altamente personalizables. Puedes integrar fácilmente tu CMS Strapi con tu aplicación Next.js para obtener un flujo de trabajo fluido y eficiente. Ya sea que estés construyendo un blog, una tienda en línea o cualquier otro tipo de aplicación web, esta poderosa combinación te brinda la flexibilidad y el control necesarios para llevar tus proyectos al siguiente nivel.

⭐ Videos Premium ⭐

Esta sección es parte del curso en Udemy. Si quieres acceder a ella, puedes comprar el curso en Udemy: React + Firebase by bluuweb.

Objetivos

Proyectos similares

Extensiones y programas

Strapi

Strapi es un sistema de gestión de contenido de código abierto (CMS: content manager system) y un marco de desarrollo de aplicaciones basado en Node.js. Proporciona a los desarrolladores y equipos la capacidad de crear y administrar fácilmente API REST listas para usar, lo que les permite construir aplicaciones web y móviles de manera más eficiente.

Algunas características clave de Strapi son:

  • Editor visual: Strapi ofrece un editor visual que permite a los usuarios crear y modificar contenido de forma intuitiva sin necesidad de conocimientos técnicos.
  • Flexibilidad y escalabilidad: Permite a los desarrolladores crear API personalizadas para adaptarse a las necesidades específicas de su proyecto y escalarlas según sea necesario.
  • Arquitectura de complementos: Strapi utiliza una arquitectura de complementos que permite a los desarrolladores extender y personalizar fácilmente las funcionalidades de la plataforma mediante complementos predefinidos o personalizados.
  • Autenticación y control de acceso: Proporciona un sistema de autenticación y control de acceso completo, lo que permite a los desarrolladores definir roles y permisos para administrar el acceso al contenido y las funcionalidades de la aplicación.
  • Comunidad activa y soporte: Strapi cuenta con una comunidad activa de desarrolladores y ofrece soporte técnico, documentación y recursos adicionales para facilitar el proceso de desarrollo.

En resumen, es tener un backend listo para usar, con una interfaz de administración, que nos permite crear y administrar contenido, y una API REST que podemos consumir desde nuestra aplicación frontend.

Alternativas a Strapi

Bases de datos soportadas

  • mysql
  • MariaDB
  • postgresql
  • SQLite

PostgreSQL

postgresql.org es un sistema de gestión de bases de datos relacional de código abierto. Es conocido comúnmente como "Postgres" y destaca por su confiabilidad, capacidad de escalabilidad y rica funcionalidad. PostgreSQL ofrece un enfoque basado en SQL para el almacenamiento y recuperación de datos y es compatible con numerosas características avanzadas.

👇🏽 Aquí un curso completo de Postgres 👇🏽

Es una opción popular tanto para aplicaciones pequeñas como grandes, y su enfoque en la integridad de los datos y la flexibilidad lo convierte en una opción atractiva para desarrolladores y organizaciones.

Railway + Strapi + Postgres

railway.app

sh
ADMIN_JWT_SECRET=Vnz80S3Rg8NADQp3O5tvgw==
JWT_SECRET=K7PAA2qgxy9naBaRL/crVg==
APP_KEYS=aivz1EugTsWKDHSFPDuK+w==,CPeMQjMQzh9dY0boM1r8fw==,0Kkva9FBdvVFtjQBsTfsQA==,6vKNxj/FHeoowOWN8kvc6w==
API_TOKEN_SALT=x8A/lKZe0NvzaNAkFSXiMw==

Cloudinary

Registre el primer usuario administrador

Ejemplo:

sh
https://strapi-production-e572.up.railway.app/admin

Ejemplo

admin
test@test.com
Blog123123

Strapi develop

Clone el repositorio que se creó a través de Railway

sh
git clone https://github.com/user/repo-url-example

Instale yarn: windows-stable

sh
npm install --global yarn

Crear .env y reemplazar PostgreSQL.DATABASE_URL por datos de Railway

sh
ADMIN_JWT_SECRET=Vnz80S3TTsdVgQp3O5tvgw==
API_TOKEN_SALT=x8A/lKZe0NvzaNAkFSXiMw==
APP_KEYS=aivz1EugTsWKDHSFPDuK+w==,CPeMQjMQzh9dY0boM1r8fw==,0Kkva9FBdvVFtjQBsTfsQA==,6vKNxj/FHeoowOWN8kvc6w==
CLOUDINARY_KEY=3123423523123
CLOUDINARY_NAME=drf324s3s
CLOUDINARY_SECRET=7FNIpeU24df34jSMvAmsKjppY
JWT_SECRET=K7PAA2qgxy9naBaRL/crVg==

## Postgres
DATABASE_URL=${{PostgreSQL.DATABASE_URL}}
PGDATABASE=${{PostgreSQL.PGDATABASE}}
PGHOST=${{PostgreSQL.PGHOST}}
PGPASSWORD=${{PostgreSQL.PGPASSWORD}}
PGPORT=${{PostgreSQL.PGPORT}}
PGUSER=${{PostgreSQL.PGUSER}}

Actualce dependencias con ^ Solo en el caso que utilicen yarn, ya que npm está tirando problemas en Railway

json
"dependencies": {
  "@strapi/plugin-i18n": "^4.10.6",
  "@strapi/plugin-users-permissions": "^4.10.6",
  "@strapi/provider-upload-cloudinary": "^4.10.6",
  "@strapi/strapi": "^4.10.6",
  "pg": "^8.8.0"
},

Ejecute servidor de desarrollo

sh
yarn install
yarn develop

Collection Type

Ejemplo: imagen strapi collection posts

Actualice github repository

sh
git add .
git commit -m "update dependencies"
git push

Railway

Al actualizar el repositorio en github, Railway detectará los cambios y actualizará el proyecto.

Crear un nuevo post

ejemplo imagen de creación de posts strapi

Habilitar API REST

ejemplo imagen habilitar api rest strapi

Consumir API REST

Todos los posts

GET http://localhost:1337/api/posts

Un post

GET http://localhost:1337/api/posts/1

Populate

La API REST de forma predeterminada no rellena ninguna relación, campo multimedia, componente o zona dinámica. Utilice el parámetro populate para completar campos específicos.

GET http://localhost:1337/api/posts?populate=*

filters

  • filters[slug]=slug-del-post
GET http://localhost:1337/api/posts?filters[slug]=slug-del-post

Sort

  • sort[createdAt]=asc
GET http://localhost:1337/api/posts?sort[createdAt]=asc

Paginación

  • pagination[page]=1&pagination[pageSize]=4
GET http://localhost:1337/api/posts?pagination[page]=1&pagination[pageSize]=4&sort[createdAt]=desc
json
"meta": {
    "pagination": {
      "page": 2, // página actual
      "pageSize": 4, // número de elementos por página
      "pageCount": 3, // total de páginas
      "total": 11 // total de elementos
    }
  }

Next.js

sh
npx create-next-app@latest .
sh
npm i qs
npm i --save-dev @types/qs

Fetch 127.0.0.1

src\app\blog\page.tsx

tsx
const getPosts = async () => {
  const res = await fetch("http://127.0.0.1:1337/api/posts?populate=*");
  const posts = await res.json();
  return posts;
};

const Blog = async () => {
  const { data } = await getPosts();

  return (
    <div>
      <h1 className="text-5xl font-extrabold dark:text-white text-center">
        Blog
      </h1>
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
};
export default Blog;

Caching Data

De manera predeterminada, todas fetch()las solicitudes se almacenan en caché y se deduplican automáticamente. Esto significa que si realiza la misma solicitud dos veces, la segunda solicitud reutilizará el resultado de la primera solicitud.

¿No quieres caché? Alternativa #01:

js
const res = await fetch("https://...", { next: { cache: "no-store" } });

Alternativa #02:

js
const res = await fetch("https://...", { next: { revalidate: 0 } });

Revalidación de datos en Next

js
fetch("https://...", { next: { revalidate: 60 } });

Cómo funciona:

  1. Cuando se realiza una solicitud a la ruta que se representó estáticamente en el momento de la compilación, inicialmente mostrará los datos almacenados en caché.
  2. Cualquier solicitud a la ruta después de la solicitud inicial y antes de 60 segundos también se almacena en caché y es instantánea.
  3. Después de la ventana de 60 segundos, la siguiente solicitud aún mostrará los datos almacenados en caché (obsoletos).
  4. Next.js activará una regeneración de los datos en segundo plano.
  5. Una vez que la ruta se genera correctamente, Next.js invalidará el caché y mostrará la ruta actualizada. Si falla la regeneración en segundo plano, los datos antiguos seguirán sin modificarse.

Helpers

src\helpers\api-helpers.ts

ts
export function getStrapiURL(path = "") {
  return `${
    process.env.NEXT_PUBLIC_STRAPI_API_URL || "http://127.0.0.1:1337"
  }${path}`;
}

src\helpers\format-date-helper.ts

ts
export const formatDate = (dateString: string) => {
  const date = new Date(dateString);
  // Intl.DateTimeFormatOptions: es una interfaz en JavaScript que representa las opciones de formato utilizadas para formatear fechas y horas
  const options: Intl.DateTimeFormatOptions = {
    year: "numeric",
    month: "long",
    day: "numeric",
  };
  return date.toLocaleDateString("es", options);
};

src\helpers\fetch-api.ts

ts
import qs from "qs";
import { getStrapiURL } from "./api-helpers";

export const fetchApi = async (
  path: string,
  urlParamsObject = {},
  options = {}
) => {
  try {
    const mergedOptions = {
      next: { revalidate: 60 },
      headers: {
        "Content-Type": "application/json",
      },
      ...options,
    };

    // Build request URL
    const queryString = qs.stringify(urlParamsObject, {
      encodeValuesOnly: true, // prettify URL
    });

    const requestUrl = `${getStrapiURL(
      `/api${path}${queryString ? `?${queryString}` : ""}`
    )}`;

    // Trigger API call
    const response = await fetch(requestUrl, mergedOptions);
    const data = await response.json();
    return data;
  } catch (error) {
    console.log(error);
    throw new Error(
      "Error al conectar la api, verifique servidor encendido, params, etc"
    );
  }
};

src\app\blog\page.tsx

tsx
import { fetchApi } from "@/helpers/fetch-api";

const getPosts = async (start = 1, pageSize = 4) => {
  const path = "/posts";
  const urlParamsObject = {
    populate: "*",
    sort: {
      createdAt: "asc",
    },
    pagination: {
      page: start,
      pageSize: pageSize,
    },
  };

  const { data, meta } = await fetchApi(path, urlParamsObject);
  return { data, pagination: meta.pagination };
};

const Blog = async () => {
  const { data } = await getPosts();

  return (
    <div>
      <h1 className="text-5xl font-extrabold dark:text-white text-center">
        Blog
      </h1>
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
};
export default Blog;

Flowbite

frontend\src\app\layout.tsx

tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="es">
      <body className={inter.className}>
        <main className="container mx-auto max-w-2xl">{children}</main>
      </body>
    </html>
  );
}

src\app\blog\page.tsx

tsx
import PageCardImage from "@/components/PageCardImage";
import PageHeader from "@/components/PageHeader";
import { fetchApi } from "@/helpers/fetch-api";
import { Post } from "@/interfaces/post";

const getPosts = async (start = 0, limit = 3) => {
  const path = "/posts";
  const urlParamsObject = {
    populate: "*",
    sort: {
      createdAt: "asc",
    },
    pagination: {
      start: start,
      limit: limit,
    },
  };

  const { data, meta } = await fetchApi(path, urlParamsObject);
  return { data: data, pagination: meta.pagination };
};

const Blog = async () => {
  const { data } = await getPosts();

  return (
    <div>
      <PageHeader text="Latest Posts" />
      <section className="grid grid-cols-1 gap-4">
        {data.map((post: Post) => (
          <PageCardImage
            key={post.id}
            post={post}
          />
        ))}
      </section>
    </div>
  );
};
export default Blog;

src\interfaces\post.ts

ts
export interface Post {
  id: number;
  attributes: Attributes2;
}

interface Attributes2 {
  title: string;
  description: string;
  body: string;
  slug: string;
  createdAt: string;
  updatedAt: string;
  publishedAt: string;
  image: Image;
}

interface Image {
  data: Data;
}

interface Data {
  id: number;
  attributes: Attributes;
}

interface Attributes {
  name: string;
  alternativeText?: any;
  caption?: any;
  width: number;
  height: number;
  formats: Formats;
  hash: string;
  ext: string;
  mime: string;
  size: number;
  url: string;
  previewUrl?: any;
  provider: string;
  provider_metadata: Providermetadata;
  createdAt: string;
  updatedAt: string;
}

interface Formats {
  large: Large;
  small: Large;
  medium: Large;
  thumbnail: Large;
}

interface Large {
  ext: string;
  url: string;
  hash: string;
  mime: string;
  name: string;
  path?: any;
  size: number;
  width: number;
  height: number;
  provider_metadata: Providermetadata;
}

interface Providermetadata {
  public_id: string;
  resource_type: string;
}

frontend\src\components\PageHeader.tsx

tsx
interface Props {
  text: string;
}

const PageHeader = ({ text }: Props) => {
  return (
    <h1 className="text-5xl font-extrabold dark:text-white text-center">
      {text}
    </h1>
  );
};
export default PageHeader;

src\components\PageCardImage.tsx

tsx
import Link from "next/link";
import { Post } from "@/interfaces/post";
import { formatDate, getStrapiURL } from "@/helpers/api-helpers";
import Image from "next/image";

interface Props {
  post: Post;
}

const PageCardImage = ({ post }: Props) => {
  const { title, description, slug, publishedAt, image } = post.attributes;
  const { url, width, height } = image.data.attributes.formats.medium;

  return (
    <div className="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700">
      <Link href={`/blog/${slug}`}>
        <Image
          className="rounded-t-lg w-full"
          src={url}
          alt={`imagen de ${title}`}
          width={width}
          height={height}
        />
      </Link>
      <div className="p-5">
        <Link href={`/blog/${slug}`}>
          <h5 className="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white">
            {title}
          </h5>
        </Link>
        <p className="text-gray-500 mb-2">{formatDate(publishedAt)}</p>
        <p className="mb-3 font-normal text-gray-700 dark:text-gray-400">
          {description}
        </p>
        <Link
          href={`/blog/${slug}`}
          className="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
        >
          Leer más
          <svg
            aria-hidden="true"
            className="w-4 h-4 ml-2 -mr-1"
            fill="currentColor"
            viewBox="0 0 20 20"
            xmlns="http://www.w3.org/2000/svg"
          >
            <path
              fillRule="evenodd"
              d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
              clipRule="evenodd"
            ></path>
          </svg>
        </Link>
      </div>
    </div>
  );
};
export default PageCardImage;

next.config.js

js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    domains: ["res.cloudinary.com"],
  },
};

module.exports = nextConfig;

Post (slug)

filters[slug]=slug-del-post

GET http://localhost:1337/api/posts?filters[slug]=slug-del-post

src\app\blog[slug].tsx

tsx
import { fetchApi } from "@/helpers/fetch-api";
import { Post } from "@/interfaces/post";

interface Props {
  params: {
    slug: string;
  };
}

const getPost = async (slug: string) => {
  const path = `/posts`;
  const urlParamsObject = {
    filter: {
      slug: slug,
    },
    populate: "image",
  };

  const { data } = await fetchApi(path, urlParamsObject);
  return data[0];
};

const Slug = async ({ params }: Props) => {
  const post: Post = await getPost(params.slug);
  return (
    <div>
      <pre>{JSON.stringify(post, null, 2)}</pre>
    </div>
  );
};
export default Slug;

Flowbite + Slug Page

frontend\src\app\blog[slug]\page.tsx

tsx
import Image from "next/image";
import { notFound } from "next/navigation";
import { formatDate, getStrapiURL } from "@/helpers/api-helpers";
import { fetchApi } from "@/helpers/fetch-api";
import { Post } from "@/interfaces/post";

interface Props {
  params: {
    slug: string;
  };
}

const getPost = async (slug: string) => {
  console.log(slug);
  const path = `/posts`;
  const urlParamsObject = {
    filters: {
      slug: slug,
    },
    populate: "image",
  };

  const { data } = await fetchApi(path, urlParamsObject);
  console.log(data);
  return data[0];
};

const Slug = async ({ params }: Props) => {
  const post: Post = await getPost(params.slug);

  if (!post) {
    notFound();
  }

  const { title, description, publishedAt, image } = post.attributes;
  const { url, width, height } = image.data.attributes.formats.medium;

  return (
    <div className="space-y-8">
      <h1 className="text-5xl font-extrabold dark:text-white">{title}</h1>
      <p className="text-gray-500 mb-2">{formatDate(publishedAt)}</p>
      <Image
        className="h-auto rounded-lg"
        src={url}
        alt={`imagen de ${title}`}
        width={width}
        height={height}
      />
      <p className="mb-3 text-gray-500 dark:text-gray-400 first-line:uppercase first-line:tracking-widest first-letter:text-7xl first-letter:font-bold first-letter:text-gray-900 dark:first-letter:text-gray-100 first-letter:mr-3 first-letter:float-left">
        {description}
      </p>
    </div>
  );
};
export default Slug;

not-found.tsx

frontend\src\app\not-found.tsx

tsx
import Link from "next/link";

export default function NotFound() {
  return (
    <div className="grid grid-cols-1 place-items-center h-screen">
      <div className="text-center space-y-4">
        <h1 className="text-3xl">Not Found</h1>
        <p>Could not find requested resource 😒</p>
        <Link
          href="/blog"
          className="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
        >
          all posts
        </Link>
      </div>
    </div>
  );
}

Pagination

  • pagination[page]=1&pagination[pageSize]=4
GET http://localhost:1337/api/posts?pagination[page]=1&pagination[pageSize]=4&sort[createdAt]=desc
json
"meta": {
    "pagination": {
      "page": 2, // página actual
      "pageSize": 4, // número de elementos por página
      "pageCount": 3, // total de páginas
      "total": 11 // total de elementos
    }
  }

frontend\src\app\blog\page.tsx

tsx
import PageCardImage from "@/components/PageCardImage";
import PageHeader from "@/components/PageHeader";
import Pagination from "@/components/Pagination";
import { fetchApi } from "@/helpers/fetch-api";
import { Post } from "@/interfaces/post";

const getPosts = async (page = 1, pageSize = 4) => {
  const path = "/posts";
  const urlParamsObject = {
    populate: "*",
    sort: {
      createdAt: "asc",
    },
    pagination: {
      page: page,
      pageSize: pageSize,
    },
  };

  const { data, meta } = await fetchApi(path, urlParamsObject);
  console.log(data);
  return { data: data, pagination: meta.pagination };
};

const Blog = async ({ searchParams }: { searchParams: { page?: string } }) => {
  const { page } = searchParams;
  let pageNumber = page ? parseInt(page) : 1;
  if (isNaN(pageNumber) || pageNumber < 1) {
    pageNumber = 1;
    console.log(
      "Valor no válido como parámetro de página. Se establece a 1. 🐤"
    );
  }

  const { data, pagination } = await getPosts(pageNumber);

  return (
    <div className="space-y-8">
      <PageHeader text="Latest Posts" />
      <Pagination pagination={pagination} />
      <section className="grid grid-cols-1 gap-4">
        {data.map((post: Post) => (
          <PageCardImage
            key={post.id}
            post={post}
          />
        ))}
      </section>
    </div>
  );
};
export default Blog;

frontend\src\components\Pagination.tsx

tsx
import Link from "next/link";

interface Props {
  pagination: {
    page: number; // página actual
    pageSize: number; // número de elementos por página
    pageCount: number; // total de páginas
    total: number; // total de elementos
  };
}

const Pagination = ({ pagination }: Props) => {
  const { page, pageSize, pageCount, total } = pagination;

  const classNumber =
    "px-3 py-2 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white";
  const classNumberActive =
    "px-3 py-2 text-blue-600 border border-gray-300 bg-blue-50 hover:bg-blue-100 hover:text-blue-700 dark:border-gray-700 dark:bg-gray-700 dark:text-white";
  const classPrevious =
    "px-3 py-2 ml-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white";
  const classNext =
    "px-3 py-2 leading-tight text-gray-500 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white";

  return (
    <nav aria-label="Page navigation example">
      <ul className="inline-flex -space-x-px">
        <li>
          <Link
            href={page === 1 ? `/blog?page=${page}` : `/blog?page=${page - 1}`}
            className={`${classPrevious} ${
              page === 1 ? "opacity-50 pointer-events-none" : ""
            }`}
          >
            Previous
          </Link>
        </li>

        {Array.from({ length: pageCount }).map((_, index) => (
          <li>
            <Link
              href={`/blog?page=${index + 1}`}
              className={index + 1 === page ? classNumberActive : classNumber}
            >
              {index + 1}
            </Link>
          </li>
        ))}

        <li>
          <Link
            href={
              page === pageCount
                ? `/blog?page=${page}`
                : `/blog?page=${page + 1}`
            }
            className={`${classNext} ${
              page === pageCount ? "opacity-50 pointer-events-none" : ""
            }`}
          >
            Next
          </Link>
        </li>
      </ul>
    </nav>
  );
};
export default Pagination;

Body: next-mdx-remote

sh
npm i next-mdx-remote

@tailwindcss/typography

El complemento de tipografía oficial Tailwind CSS proporciona un conjunto de clases prose que puede usar para agregar hermosos valores predeterminados tipográficos a cualquier HTML básico que no controle, como HTML renderizado desde Markdown o extraído de un CMS.

sh
npm install -D @tailwindcss/typography

tailwind.config.js

js
module.exports = {
  theme: {
    // ...
  },
  plugins: [
    require("@tailwindcss/typography"),
    // ...
  ],
};

frontend\src\app\blog[slug]\page.tsx

tsx
import { MDXRemote } from "next-mdx-remote/rsc";

import { notFound } from "next/navigation";
import { formatDate, getStrapiURL } from "@/helpers/api-helpers";
import { fetchApi } from "@/helpers/fetch-api";
import { Post } from "@/interfaces/post";
import Image from "next/image";

interface Props {
  params: {
    slug: string;
  };
}

const getPost = async (slug: string) => {
  const path = `/posts`;
  const urlParamsObject = {
    filters: {
      slug: slug,
    },
    populate: "image",
  };

  const { data } = await fetchApi(path, urlParamsObject);
  return data[0];
};

const Slug = async ({ params }: Props) => {
  const post: Post = await getPost(params.slug);

  if (!post) {
    notFound();
  }

  const { title, description, publishedAt, image, body } = post.attributes;
  const { url, width, height } = image.data.attributes.formats.medium;

  return (
    <div className="space-y-8">
      <h1 className="text-5xl font-extrabold dark:text-white">{title}</h1>
      <p className="text-gray-500 mb-2">{formatDate(publishedAt)}</p>
      <Image
        className="h-auto rounded-lg"
        src={url}
        alt={`imagen de ${title}`}
        width={width}
        height={height}
      />
      <p className="mb-3 text-gray-500 dark:text-gray-400 first-line:uppercase first-line:tracking-widest first-letter:text-7xl first-letter:font-bold first-letter:text-gray-900 dark:first-letter:text-gray-100 first-letter:mr-3 first-letter:float-left">
        {description}
      </p>
      <div className="prose">
        {/* Este error en particular está codificado en TypeScript. El equipo de React está trabajando con el equipo de TypeScript para resolver esto. */}
        {/* https://github.com/vercel/next.js/issues/42292 */}
        {/* @ts-expect-error Server Component */}
        <MDXRemote source={body} />
      </div>
    </div>
  );
};
export default Slug;

nextjs-toploader

nextjs-toploader es un componente de barra de carga superior para Next.js hecho con nprogress.

sh
npm install nextjs-toploader
tsx
import NextTopLoader from "nextjs-toploader";

...

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="es">
      <body className={inter.className}>
        <NextTopLoader color="#000" />
        <main className="container mx-auto max-w-2xl mt-4 space-y-4">
          {children}
        </main>
      </body>
    </html>
  );
}

Framer Motion

Framer Motion es una biblioteca de animación para React. Framer Motion es fácil de usar y ofrece una sintaxis declarativa que permite escribir menos código.

frontend\src\components\PageTransition.tsx

tsx
"use client";

import { motion } from "framer-motion";

interface Props {
  children: React.ReactNode;
}

const PageTransition = ({ children }: Props) => {
  return (
    <motion.div
      initial={{ y: 12, opacity: 0 }}
      animate={{ y: 0, opacity: 1 }}
      exit={{ y: 12, opacity: 0 }}
      transition={{
        type: "spring",
        stiffness: 260,
        damping: 40,
        duration: 0.2,
      }}
    >
      {children}
    </motion.div>
  );
};
export default PageTransition;
tsx
import PageTransition from "@/components/PageTransition";

...

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="es">
      <body className={inter.className}>
        <NextTopLoader color="#000" />
        <main className="container mx-auto max-w-2xl mt-4 space-y-4">
          <PageTransition>{children}</PageTransition>
        </main>
      </body>
    </html>
  );
}

clsx & tailwind-merge

  • clsx: El módulo clsx se puede usar para construir cadenas de className condicionalmente en función de la veracidad de los valores que se le pasan.
sh
npm install clsx

Ejemplo:

js
// EXTRACTED FROM https://github.com/lukeed/clsx
import clsx from "clsx";

// Strings (variadic)
clsx("foo", true && "bar", "baz");
//=> 'foo bar baz'

// Objects
clsx({ foo: true, bar: false, baz: isTrue() });
//=> 'foo baz'

// Objects (variadic)
clsx({ foo: true }, { bar: false }, null, { "--foobar": "hello" });
//=> 'foo --foobar'

// Arrays
clsx(["foo", 0, false, "bar"]);
//=> 'foo bar'

// Arrays (variadic)
clsx(["foo"], ["", 0, false, "bar"], [["baz", [["hello"], "there"]]]);
//=> 'foo bar baz hello there'

// Kitchen sink (with nesting)
clsx(
  "foo",
  [1 && "bar", { baz: false, bat: null }, ["hello", ["world"]]],
  "cya"
);
//=> 'foo bar hello world cya'
sh
npm install tailwind-merge

Ejemplo:

js
import { twMerge } from "tailwind-merge";

twMerge("px-2 py-1 bg-red hover:bg-dark-red", "p-3 bg-[#B91C1C]");
// → 'hover:bg-dark-red p-3 bg-[#B91C1C]'

helpers/classnames.ts

ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

Pagination + cn

tsx
<Link
  href={page === 1 ? `/blog?page=${page}` : `/blog?page=${page - 1}`}
  className={cn(classPrevious, {
    "opacity-50 pointer-events-none": page === 1,
  })}
>
  Previous
</Link>

<Link
  href={
    page === pageCount
      ? `/blog?page=${page}`
      : `/blog?page=${page + 1}`
  }
  className={cn(classNext, {
    "opacity-50 pointer-events-none": page === pageCount,
  })}
>
  Next
</Link>

Flowbite (JS) + Next.js

sh
npm install flowbite flowbite-react

frontend\tailwind.config.js

js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./node_modules/flowbite-react/**/*.js",
    "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
    "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
    "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
  ],
  theme: {
    extend: {
      backgroundImage: {
        "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
        "gradient-conic":
          "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
      },
    },
  },
  plugins: [require("@tailwindcss/typography"), require("flowbite/plugin")],
};

Ejemplo:

js
"use client";

import { Alert } from "flowbite-react";

export default function MyPage() {
  return <Alert color="info">Alert!</Alert>;
}

Header.tsx + Flowbite

frontend\src\components\Header.tsx

tsx
"use client";

import { Navbar } from "flowbite-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/helpers/classnames";
// import { useContext } from "react";
// import { cartContext } from "@/context/CartContext";

const navLinks = [
  {
    href: "/",
    text: "Home",
  },
  {
    href: "/blog",
    text: "Blog",
  },
  {
    href: "/store",
    text: "Store",
  },
  {
    href: "/cart",
    text: "Cart",
  },
];

const Header = () => {
  const pathname = usePathname();
  // const { totalQuantityProduct } = useContext(cartContext);

  return (
    <Navbar
      fluid={true}
      rounded={true}
    >
      <Navbar.Brand href="https://flowbite.com/">
        <img
          src="https://flowbite.com/docs/images/logo.svg"
          className="mr-3 h-6 sm:h-9"
          alt="Flowbite Logo"
        />
        <span className="self-center whitespace-nowrap text-xl font-semibold dark:text-white">
          Strapi
        </span>
      </Navbar.Brand>
      <Navbar.Toggle />
      <Navbar.Collapse>
        {navLinks.map((navLink) => (
          <Navbar.Link
            key={navLink.href}
            href={navLink.href}
            active={pathname === navLink.href}
            as={Link}
            className={cn(
              pathname === navLink.href && "md:text-blue-500 bg-gray-950"
            )}
          >
            <span className="relative">
              {navLink.text}
              {/* {navLink.text === "Cart" && (
                <div className="absolute inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 rounded-full -top-2 -right-5">
                  {totalQuantityProduct}
                </div>
              )} */}
            </span>
          </Navbar.Link>
        ))}
      </Navbar.Collapse>
    </Navbar>
  );
};
export default Header;

Book (strapi)

  • title: Text
  • slug: UID
  • image: Media
  • price: Number
  • stock: Number
  • description: Text

Store

frontend\src\app\store\page.tsx

tsx
import PageCardStore from "@/components/PageCardStore";
import PageHeader from "@/components/PageHeader";
import Pagination from "@/components/Pagination";
import { fetchApi } from "@/helpers/fetch-api";
import { Book } from "@/interfaces/book";

const getBooks = async (page = 1, pageSize = 4) => {
  const path = "/books";
  const urlParamsObject = {
    populate: "*",
    sort: {
      createdAt: "asc",
    },
    pagination: {
      page: page,
      pageSize: pageSize,
    },
  };

  const { data, meta } = await fetchApi(path, urlParamsObject);
  return { data: data, pagination: meta.pagination };
};

const Store = async ({ searchParams }: { searchParams: { page?: string } }) => {
  const { page } = searchParams;
  let pageNumber = page ? parseInt(page) : 1;
  if (isNaN(pageNumber) || pageNumber < 1) {
    pageNumber = 1;
    console.log(
      "Valor no válido como parámetro de página. Se establece a 1. 🐤"
    );
  }

  const { data, pagination } = await getBooks(pageNumber);

  return (
    <div className="space-y-8">
      <PageHeader text="Book Store" />
      <Pagination pagination={pagination} />
      <section className="grid grid-cols-1 gap-4">
        {data.map((book: Book) => (
          <PageCardStore
            key={book.id}
            book={book}
          />
        ))}
      </section>
    </div>
  );
};
export default Store;

frontend\src\interfaces\book.ts

ts
export interface Book {
  id: number;
  attributes: Attributes2;
}

interface Attributes2 {
  title: string;
  description: string;
  slug: string;
  price: number;
  stock: number;
  createdAt: string;
  updatedAt: string;
  publishedAt: string;
  image: Image;
}

interface Image {
  data: Data;
}

interface Data {
  id: number;
  attributes: Attributes;
}

interface Attributes {
  name: string;
  alternativeText?: any;
  caption?: any;
  width: number;
  height: number;
  formats: Formats;
  hash: string;
  ext: string;
  mime: string;
  size: number;
  url: string;
  previewUrl?: any;
  provider: string;
  provider_metadata?: any;
  createdAt: string;
  updatedAt: string;
}

interface Formats {
  large: Large;
  small: Large;
  medium: Large;
  thumbnail: Large;
}

interface Large {
  ext: string;
  url: string;
  hash: string;
  mime: string;
  name: string;
  path?: any;
  size: number;
  width: number;
  height: number;
}

frontend\src\components\PageCardStore.tsx

tsx
"use client";

import { useContext } from "react";
import { useRouter } from "next/navigation";
import { Book } from "@/interfaces/book";
import { getStrapiURL } from "@/helpers/api-helpers";
import Image from "next/image";
import { cn } from "@/helpers/classnames";
import { cartContext } from "@/context/CartContext";
import { formatPrice } from "@/helpers/format-price";

interface Props {
  book: Book;
}

const PageCardImage = ({ book }: Props) => {
  const { id } = book;
  const { title, description, price, image, stock } = book.attributes;
  const { url, width, height } = image.data.attributes.formats.medium;

  const { addCartProducts } = useContext(cartContext);
  const router = useRouter();

  const handleAddToCart = () => {
    addCartProducts({ id, title, price });
    router.push("/cart");
  };

  return (
    <div className="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700">
      <Image
        className="rounded-t-lg w-full"
        src={getStrapiURL(url)}
        alt={`imagen de ${title}`}
        width={width}
        height={height}
      />

      <div className="p-5">
        <h5 className="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white">
          {title}
        </h5>

        <p className="text-gray-500 mb-2 text-lg">
          Precio: ${formatPrice(price)}
        </p>
        <p className="text-gray-500 mb-2 text-lg">
          Stock: {formatPrice(stock)} unidades
        </p>
        <p className="mb-3 font-normal text-gray-700 dark:text-gray-400">
          {description}
        </p>
        <button
          onClick={handleAddToCart}
          className={cn(
            "inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800",
            stock === 0 && "pointer-events-none opacity-50"
          )}
        >
          {stock === 0 ? "Sin stock" : "Comprar"}
          <svg
            aria-hidden="true"
            className="w-4 h-4 ml-2 -mr-1"
            fill="currentColor"
            viewBox="0 0 20 20"
            xmlns="http://www.w3.org/2000/svg"
          >
            <path
              fillRule="evenodd"
              d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
              clipRule="evenodd"
            ></path>
          </svg>
        </button>
      </div>
    </div>
  );
};
export default PageCardImage;

frontend\src\helpers\format-price.ts

ts
export const formatPrice = (price: number) => {
  return price.toLocaleString("es", { maximumFractionDigits: 0 });
};

Context API + Next 13

frontend\src\context\CartContext.tsx

tsx
"use client";
import { useState, createContext } from "react";

interface ProductCart {
  id: number;
  title: string;
  price: number;
  quantity: number;
}

interface ProductCartContext {
  cartProducts: ProductCart[];
  addCartProducts: (product: ProductCartItem) => void;
  increaseQuantity: (id: number) => void;
  decreaseQuantity: (id: number) => void;
  totalQuantityProduct: number;
  totalPriceProduct: number;
}

interface ProductCartItem {
  id: number;
  title: string;
  price: number;
}

interface Props {
  children: React.ReactNode;
}

export const cartContext = createContext({} as ProductCartContext);

export default ({ children }: Props) => {
  const [cartProducts, setCartProducts] = useState<ProductCart[]>([]);

  const addCartProducts = ({ id, title, price }: ProductCartItem) => {
    if (cartProducts.length === 0) {
      return setCartProducts([{ id, title, price, quantity: 1 }]);
    }

    const productExist = cartProducts.find((item) => item.id === id);

    if (!productExist) {
      return setCartProducts([
        ...cartProducts,
        { id, title, price, quantity: 1 },
      ]);
    }

    setCartProducts(
      cartProducts.map((item) => {
        if (item.id === id) {
          return {
            ...item,
            quantity: item.quantity + 1,
          };
        } else {
          return item;
        }
      })
    );
  };

  const increaseQuantity = (id: number) => {
    setCartProducts(
      cartProducts.map((item) => {
        if (item.id === id) {
          return {
            ...item,
            quantity: item.quantity + 1,
          };
        } else {
          return item;
        }
      })
    );
  };

  const decreaseQuantity = (id: number) => {
    if (cartProducts.find((item) => item.id === id)?.quantity === 1) {
      return setCartProducts(cartProducts.filter((item) => item.id !== id));
    }

    setCartProducts(
      cartProducts.map((item) => {
        if (item.id === id) {
          return {
            ...item,
            quantity: item.quantity - 1,
          };
        } else {
          return item;
        }
      })
    );
  };

  const totalQuantityProduct = cartProducts.reduce(
    (acc, current) => current.quantity + acc,
    0
  );

  const totalPriceProduct = cartProducts.reduce(
    (acc, current) => current.price * current.quantity + acc,
    0
  );

  return (
    <cartContext.Provider
      value={{
        cartProducts,
        addCartProducts,
        increaseQuantity,
        decreaseQuantity,
        totalQuantityProduct,
        totalPriceProduct,
      }}
    >
      {children}
    </cartContext.Provider>
  );
};

frontend\src\app\layout.tsx

tsx
import NextTopLoader from "nextjs-toploader";

import "./globals.css";
import { Inter } from "next/font/google";

import CartContextProvider from "@/context/CartContext";

import PageTransition from "@/components/PageTransition";
import Header from "@/components/Header";

const inter = Inter({ subsets: ["latin"] });

export const metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="es">
      <body className={inter.className}>
        <NextTopLoader color="#000" />
        <main className="container mx-auto max-w-2xl mt-4 space-y-4 px-6">
          <CartContextProvider>
            <Header />
            <PageTransition>{children}</PageTransition>
          </CartContextProvider>
        </main>
      </body>
    </html>
  );
}

Cart

frontend\src\app\cart\page.tsx

tsx
"use client";

import { useContext } from "react";
import { Table } from "flowbite-react";

import PageHeader from "@/components/PageHeader";
import { cartContext } from "@/context/CartContext";
import { formatPrice } from "@/helpers/format-price";

const Cart = () => {
  const {
    cartProducts,
    increaseQuantity,
    decreaseQuantity,
    totalQuantityProduct,
    totalPriceProduct,
  } = useContext(cartContext);

  return (
    <div className="space-y-8">
      <PageHeader text="Book Cart" />
      {/* <pre>{JSON.stringify(cartProducts, null, 2)}</pre> */}
      <Table>
        <Table.Head>
          <Table.HeadCell>
            {totalQuantityProduct !== 0 ? "Product name" : "Cart is empty"}
          </Table.HeadCell>
          <Table.HeadCell className="text-right"></Table.HeadCell>
        </Table.Head>
        <Table.Body className="divide-y">
          {cartProducts.map((product) => (
            <Table.Row className="bg-white dark:border-gray-700 dark:bg-gray-800">
              <Table.Cell className="whitespace-nowrap font-medium text-gray-900 dark:text-white">
                {product.title}
              </Table.Cell>
              <Table.Cell className="">
                <div className="flex justify-end items-center space-x-3">
                  <button
                    className="inline-flex items-center p-1 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-full focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700"
                    type="button"
                    onClick={() => decreaseQuantity(product.id)}
                  >
                    <span className="sr-only">Quantity button</span>
                    <svg
                      className="w-4 h-4"
                      aria-hidden="true"
                      fill="currentColor"
                      viewBox="0 0 20 20"
                      xmlns="http://www.w3.org/2000/svg"
                    >
                      <path
                        fillRule="evenodd"
                        d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
                        clipRule="evenodd"
                      />
                    </svg>
                  </button>
                  <div>
                    <span className="bg-gray-50 w-10 text-center border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block px-2.5 py-1 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
                      {product.quantity}
                    </span>
                  </div>
                  <button
                    className="inline-flex items-center p-1 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-full focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700"
                    type="button"
                    onClick={() => increaseQuantity(product.id)}
                  >
                    <span className="sr-only">Quantity button</span>
                    <svg
                      className="w-4 h-4"
                      aria-hidden="true"
                      fill="currentColor"
                      viewBox="0 0 20 20"
                      xmlns="http://www.w3.org/2000/svg"
                    >
                      <path
                        fillRule="evenodd"
                        d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
                        clipRule="evenodd"
                      />
                    </svg>
                  </button>
                </div>
              </Table.Cell>
            </Table.Row>
          ))}
          {/* // footer */}
          <Table.Row className="bg-white dark:border-gray-700 dark:bg-gray-800 font-extrabold">
            <Table.Cell className="whitespace-nowrap font-extrabold text-gray-900 dark:text-white ">
              Total
            </Table.Cell>
            <Table.Cell className="text-right">
              <span className="text-gray-900 dark:text-white">
                ${formatPrice(totalPriceProduct)}
              </span>
            </Table.Cell>
          </Table.Row>
        </Table.Body>
      </Table>
    </div>
  );
};
export default Cart;