Parte 2: Cargar posts en la página de inicio y detalle del post con Svelte 5 y Sveltekit
En esta segunda parte, finalizaremos la página de inicio mostrando los posts más recientes y aprenderemos a crear endpoints que funcionarán como la API de nuestra aplicación utilizando Svelte 5 y SvelteKit. Estos endpoints nos permitirán implementar un scroll infinito, cargando nuevos posts a medida que el usuario llegue al final de la página hasta completar la lista de artículos. Además, diseñaremos la página de detalle para visualizar cada post en su totalidad.
Antes de empezar 🛑 y como siempre, si te has perdido alguno de los tutoriales de esta serie, aquí te dejo todos los tutoriales escritos hasta ahora:
- Crea una Web Fullstack con Svelte 5 y SvelteKit
- Enrutamiento y Cabecera Responsive en SvelteKit: Configuración Inicial para Tu Blog
- Configura PostgreSQL con Drizzle ORM en SvelteKit: Base de datos del blog
- Parte 1: Cargar posts en la página de inicio y detalle del post con Svelte 5 y Sveltekit
- Parte 2: Cargar posts en la página de inicio y detalle del post con Svelte 5 y Sveltekit 🚩
- Siguiente parte en construcción 👷
Crear endpoint para obtener artículos
Con Svelte 5 y SvelteKit, podemos construir una API para obtener los recursos que necesitemos, en este caso, los posts que se cargarán después de los iniciales y, posteriormente, los siguientes tras cada nueva carga.
Para lograrlo, dentro de la carpeta routes
, crearemos una estructura específica: primero, una carpeta llamada api
para organizar las rutas relacionadas con la API, y dentro de ella, otra carpeta llamada blog
.
En esta última, añadiremos un archivo llamado +server.ts
, donde implementaremos toda la lógica necesaria para manejar las solicitudes. Esta configuración permitirá realizar peticiones a la ruta /api/blog
. Ahora, vamos a crear el archivo correspondiente en el proyecto: src/routes/api/blog/+server.ts
en el que añadiremos el siguiente contenido:
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getPosts } from '$lib/server/db/posts';
import { getFirstParagraph } from '$lib/utils/html';
export const POST: RequestHandler = async ({ request }) => {
const { offset } = await request.json();
if (typeof offset !== 'number' || offset < 0) {
return json({ error: 'Offset must be a number and greater than or equal to 0' }, { status: 400 });
}
const posts = await getPosts(5, offset);
return json({
posts: posts.map((post) => {
post.content = getFirstParagraph(post.content);
return post;
})
});
}
Este código implementa un endpoint que maneja solicitudes POST para recuperar un conjunto de posts desde el servidor. A continuación, desglosamos su funcionamiento:
- Importaciones:
json
de SvelteKit: utilizado para estructurar la respuesta JSON que se enviará al cliente.RequestHandler
: un tipo de SvelteKit que define la estructura de los manejadores de solicitudes.getPosts
: la función que previamente creamos y que recupera los posts desde la base de datos.getFirstParagraph
: una utilidad para extraer el primer párrafo del contenido de un post.
- Definición del manejador:
Elexport const POST
define que este endpoint solo manejará solicitudes con el método HTTP POST. En el futuro, podrían agregarse otros manejadores para métodos como GET, PUT o DELETE. - Procesamiento del cuerpo de la solicitud:
- Se extrae el valor de
offset
del cuerpo de la solicitud usandoawait request.json()
. Este valor indica desde qué posición deben cargarse los posts. - Se valida que
offset
sea un número mayor o igual a 0. Si no lo es, se devuelve un mensaje de error con un estado HTTP 400 (Bad Request).
- Se extrae el valor de
- Obtención de los posts:
- Se llama a la función
getPosts
con un límite fijo de 5 posts y eloffset
especificado. - Cada post recuperado es procesado para sustituir su contenido completo por solo el primer párrafo, utilizando la función
getFirstParagraph
.
- Se llama a la función
- Respuesta:
- Se devuelve un objeto JSON que contiene la lista de posts procesados.
Mostrar artículos en la página principal
Para mostrar los artículos en la página principal, vamos a ir al archivo src\routes\+page.svelte
y primero vamos a añadir la parte de TypeScript que será la siguiente:
<script lang="ts">
import { ArticleMain } from '$components';
import type { PostWithUser } from '$lib/server/db/posts';
import { onMount } from 'svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let posts: PostWithUser[] = $state(data.posts);
let offset: number = $state(0);
let isLoading: boolean = $state(false);
let hasMorePosts: boolean = $state(true);
async function loadMorePosts() {
if (!hasMorePosts || isLoading) {
return;
}
isLoading = true;
offset += 5;
try {
const response = await fetchPosts(offset);
if (response.status === 200) {
const responsePost = await response.json();
let newPosts: PostWithUser[] = responsePost.posts;
newPosts = newPosts.map((post) => {
post.createdAt = new Date(post.createdAt);
return post;
});
if (newPosts.length === 0) {
hasMorePosts = false;
} else {
posts = [...posts, ...newPosts];
if (newPosts.length < 5) {
hasMorePosts = false;
}
}
} else {
const error = await response.json();
console.error('Error:', error.error || 'Unexpected error');
}
} catch (err) {
console.error('Network or server error:', err);
}
isLoading = false;
}
async function fetchPosts(offset: number): Promise<Response> {
return new Promise((resolve, reject) => {
setTimeout(async () => {
try {
const response = await fetch('/api/blog', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ offset })
});
if (response.ok) {
resolve(response);
} else {
reject(new Error(`Failed with status: ${response.status}`));
}
} catch (err) {
reject(err);
}
}, 2000);
});
}
onMount(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !isLoading) {
loadMorePosts();
}
},
{ threshold: 1.0 }
);
const target = document.querySelector('#load-more');
if (target) observer.observe(target);
return () => observer.disconnect();
});
</script>
Este código implementa un sistema de scroll infinito para cargar y mostrar posts en una página. A continuación, explico detalladamente su funcionamiento:
Importaciones
- Componentes y Tipos:
ArticleMain
: Componente previamente creado para mostrar la estructura de cada post.PostWithUser
: Tipo que define la estructura de un post, incluyendo sus atributos, lo que ayuda a prevenir errores al trabajar con los datos.PageData
: Tipo que contiene los datos enviados desde el archivosrc/routes/+page.server.ts
.
- Funciones y Hooks de Svelte:
onMount
: Hook que se ejecuta cuando el componente se monta en el DOM.
Declaración de Variables
posts
: Almacena el listado actual de posts visibles en la página.offset
: Representa la posición actual desde donde se cargarán más posts.isLoading
: Indica si se está realizando una carga de posts para evitar múltiples solicitudes simultáneas.hasMorePosts
: Determina si quedan más posts por cargar. Si esfalse
, detiene las peticiones adicionales.
Funciones Principales
loadMorePosts
Esta función gestiona la lógica para cargar más posts:
- Condiciones Iniciales:
- No realiza ninguna acción si
isLoading
estrue
o sihasMorePosts
esfalse
.
- No realiza ninguna acción si
- Carga de Posts:
- Marca
isLoading
comotrue
y actualiza eloffset
para cargar el siguiente grupo de posts. - Llama a la función
fetchPosts
para realizar la solicitud a la API. - Si la respuesta contiene nuevos posts:
- Convierte las fechas de los posts al tipo
Date
(para manipulación posterior). - Agrega los nuevos posts al listado existente.
- Si el número de nuevos posts es menor que el límite (5), asumimos que no hay más posts por cargar y actualiza
hasMorePosts
.
- Convierte las fechas de los posts al tipo
- Si no hay posts en la respuesta, establece
hasMorePosts
enfalse
.
- Marca
- Errores:
- Maneja errores de red o de la API, registrándolos en la consola, más adelante podemos añadir un mensaje de error para el usuario y nosotros guardarnos la información para corregirlo posteriormente.
- Finalización:
- Marca
isLoading
comofalse
al terminar la carga.
- Marca
fetchPosts
Se encarga de realizar la solicitud a la API para recuperar los posts:
- Simulación de Retardo:
- Usa
setTimeout
para simular un retraso en la carga (2 segundos).
- Usa
- Solicitud HTTP:
- Envía una petición POST a
/api/blog
con eloffset
en el cuerpo de la solicitud. - Si la respuesta es exitosa (
status 200
), devuelve la respuesta; en caso contrario, lanza un error.
- Envía una petición POST a
onMount
e Intersection Observer
onMount
inicializa un IntersectionObserver que detecta cuándo el usuario llega al final de la página:
- Configuración:
- Observa un elemento con el ID
#load-more
. - Si el elemento es visible y no se está cargando, llama a
loadMorePosts
.
- Observa un elemento con el ID
- Limpieza:
- Desconecta el observador cuando el componente se desmonta.
Una vez hecho esto, vamos a añadir en este mismo archivo la parte HTML que será la siguiente:
{#each posts as post}
<ArticleMain {post} />
{/each}
{#if isLoading}
<div class="loading">
<div class="spinner"></div>
</div>
{/if}
<div id="load-more" class="load-more-posts"></div>
{#if !hasMorePosts}
<div class="message-no-posts">No hay más artículos</div>
{/if}
1. Renderizado de Posts
{#each posts as post}
<ArticleMain {post} />
{/each}
- La directiva
{#each}
recorre el array deposts
y renderiza el componenteArticleMain
para cada post. - Cada elemento del array
posts
se pasa como una prop (post
) al componenteArticleMain
.
2. Indicador de Carga (Spinner)
{#if isLoading}
<div class="loading">
<div class="spinner"></div>
</div>
{/if}
- Si la variable
isLoading
estrue
, se muestra un contenedor con un spinner. - Este indicador informa al usuario que se están cargando más artículos.
- El diseño del spinner lo definiremos después con CSS.
3. Punto de Observación para Scroll Infinito
<div id="load-more" class="load-more-posts"></div>
- Este
div
actúa como un marcador en la página. - Se utiliza con un
IntersectionObserver
para detectar cuándo el usuario alcanza el final de la lista de posts. - Al hacerse visible este elemento, se activa la carga de más posts si corresponde.
4. Mensaje Sin Más Posts
{#if !hasMorePosts}
<div class="message-no-posts">No hay más artículos</div>
{/if}
- Si la variable
hasMorePosts
esfalse
, significa que no quedan más posts por cargar. - Se muestra un mensaje informativo para que el usuario sepa que llegó al final de la lista.
Por último, añadiremos los estilos CSS para crear el spinner
y para darle formato al mensaje cuando llegamos al punto de que no hay más posts:
<style>
.loading {
text-align: center;
padding: 1rem;
}
.spinner {
width: 30px;
height: 30px;
border: 4px solid #ccc;
border-top-color: #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.load-more-posts {
height: 1px;margin-bottom:1rem;
}
.message-no-posts {
width: 100%;
text-align: center;
font-size: 1.2rem;
color: #6c6c6c;
margin-bottom: 1rem;
}
</style>
Mostrar artículo en detalle
Para finalizar este tutorial, vamos a crear la página de detalle para los posts. Para ello, dentro de la carpeta routes
, crearemos una nueva carpeta llamada blog
. Dentro de esta, añadiremos otra carpeta denominada [slug]
.
En Svelte
, las carpetas con nombres entre corchetes ([ ]
) representan parámetros dinámicos de ruta. Esto significa que su valor será variable y dependerá del dato que recibamos. En este caso, utilizaremos el valor almacenado en la base de datos en el campo slugUrl
. De esta forma podremos capturar dicho valor y buscar en la base de datos el post correspondiente que coincida con él.
A continuación, creamos el archivo src\routes\blog\[slug]\+page.server.ts
y añadimos el siguiente contenido:
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { getPostBySlugUrl } from '$lib/server/db/posts';
export const load: PageServerLoad = async ({ params }) => {
const post = await getPostBySlugUrl(params.slug)
if (post) {
return {post};
}
error(404, 'Not found');
};
En este código, importamos tres elementos clave:
error
de SvelteKit, que se utiliza para lanzar errores HTTP personalizados.- El tipo
PageServerLoad
que ya lo usamos anteriormente y que nos ayuda a tipar la funciónload
para garantizar que seguimos el formato esperado por SvelteKit. - La función
getPostBySlugUrl
, que recupera un post de la base de datos en función de suslugUrl
.
Dentro de la función load
, utilizamos params.slug
para acceder al parámetro dinámico de la ruta (en este caso, el valor del slug
). Luego, llamamos a la función getPostBySlugUrl
para intentar obtener el post correspondiente.
- Si el post existe, lo retornamos como un objeto
{ post }
. - Si no encontramos un post con ese
slugUrl
, utilizamoserror(404, 'Not found')
para generar un error HTTP 404. Esto mostrará una página de error según el diseño definido en el archivo+layout.svelte
de la aplicación.
El siguiente paso es crear el componente para el artículo. Para ello creamos el archivo src\lib\components\Article\ArticleDetail.svelte
con el siguiente contenido:
<script lang="ts">
import { ArticleFooter } from '$components';
import type { PostWithUser } from '$lib/server/db/posts';
let { post }: { post: PostWithUser } = $props();
</script>
<article class="post">
<div class="image-post">
<img src={`../${post.imageHeader}`} alt="" />
</div>
<div class="title-post">
<h1>{post.title}</h1>
</div>
<div class="separator"></div>
<div class="content-post">
{@html post.content}
</div>
<ArticleFooter
slug={post.slugUrl}
username={post.username}
createdAt={post.createdAt}
avatar={post.avatar}
/>
</article>
<style>
.title-post h1 {
margin-top: 1rem;
font-size: 2.7rem;
color: #fe5c71;
}
</style>
Como ves, es bastante similar al componente ArticleMain.svelte
pero con unos pequeños cambios a la hora de diseño.
En este paso, al igual que en el tutorial anterior, centralizamos las exportaciones de los componentes en el archivo src/lib/components/index.ts
.
export {default as Header} from '$components/layout/Header.svelte';
export {default as ArticleMain} from '$components/Article/ArticleMain.svelte';
export {default as ArticleDetail} from '$components/Article/ArticleDetail.svelte';
export {default as ArticleFooter} from '$components/Article/ArticleFooter.svelte';
Vamos a realizar una pequeña modificación en el archivo src/lib/components/layout/Header.svelte
para optimizar la estructura del HTML. Ajustaremos el encabezado de manera que solo utilice la etiqueta <h1>
cuando estemos en la página principal.
Este cambio es importante porque permite reservar la etiqueta <h1>
para el título del artículo en las páginas de detalle, lo cual mejora la semántica del documento y puede contribuir a un mejor posicionamiento en buscadores (SEO) si en el futuro decidimos enfocarnos en la optimización para motores de búsqueda.
<div class="content-second-header">
<a href="/">
<img id="image-header" src={HeaderImg} alt="Cabecera cosasdedevs" />
</a>
{#if $page.url.pathname === '/'}
<h1>Artítulos y tutoriales del mundo tech</h1>
{/if}
</div>
Ahora ya solo necesitamos crear la parte front para poder visualizar el artículo en detalle. Para ello crearemos el archivo src\routes\blog\[slug]\+page.svelte
con el siguiente contenido:
<script lang="ts">
import { ArticleDetail } from '$components';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<ArticleDetail post={data.post} />
Como puedes observar, son acciones que ya hemos realizado antes. Primero importamos el componente ArticleDetail
, recuperamos los datos recibidos por parte del servidor y le enviamos la información al componente.
Y eso es todo por este tutorial. Recordad que cualquier duda podéis escribirla en la caja de comentarios y también os dejo el enlace a la rama que corresponde a este tutorial por si tenéis cualquier problema:
https://github.com/albertorc87/blog-svelte-5/tree/tutorial-4-pagina-principal-lista-posts
Espero que este post te ayude y como siempre, te recomiendo seguirme en Twitter para estar al tanto de los nuevo contenido. Ahora también puedes seguirme en Instagram donde estoy subiendo tips, tutoriales en vídeo e información sobre herramientas para developers.
Por último os dejo mi guía para aprender a trabajar con APIs donde explico todo el funcionamiento de una API, el protocolo HTTP y veremos como construir una API con arquitectura REST.
Nos leemos 👋.