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
- Crear un proyecto con Next.js y Strapi
- Frontend Code
- Backend Code
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
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:
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
git clone https://github.com/user/repo-url-example
Instale yarn: windows-stable
npm install --global yarn
Crear .env
y reemplazar PostgreSQL.DATABASE_URL
por datos de Railway
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
"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
yarn install
yarn develop
Collection Type
Ejemplo:
Actualice github repository
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
Habilitar API REST
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
"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
npx create-next-app@latest .
npm i qs
npm i --save-dev @types/qs
Fetch 127.0.0.1
src\app\blog\page.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:
const res = await fetch("https://...", { next: { cache: "no-store" } });
Alternativa #02:
const res = await fetch("https://...", { next: { revalidate: 0 } });
Revalidación de datos en Next
fetch("https://...", { next: { revalidate: 60 } });
Cómo funciona:
- 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é.
- 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.
- Después de la ventana de 60 segundos, la siguiente solicitud aún mostrará los datos almacenados en caché (obsoletos).
- Next.js activará una regeneración de los datos en segundo plano.
- 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
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
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
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
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
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
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
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
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
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
/** @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
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
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
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
"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
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
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
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.
npm install -D @tailwindcss/typography
tailwind.config.js
module.exports = {
theme: {
// ...
},
plugins: [
require("@tailwindcss/typography"),
// ...
],
};
frontend\src\app\blog[slug]\page.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.
npm install nextjs-toploader
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
"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;
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.
npm install clsx
Ejemplo:
// 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'
- tailwind-merge: Una utilidad para combinar clases de Tailwind CSS.
npm install tailwind-merge
Ejemplo:
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
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Pagination + cn
<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
npm install flowbite flowbite-react
frontend\tailwind.config.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:
"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
"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
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
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
"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
export const formatPrice = (price: number) => {
return price.toLocaleString("es", { maximumFractionDigits: 0 });
};
Context API + Next 13
frontend\src\context\CartContext.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
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
"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;