logo cosasdedevs
Parte 2: 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



My Profile
Ene 07, 2025

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:

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:

  1. 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.
  2. Definición del manejador:
    El export 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.
  3. Procesamiento del cuerpo de la solicitud:
    • Se extrae el valor de offset del cuerpo de la solicitud usando await 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).
  4. Obtención de los posts:
    • Se llama a la función getPosts con un límite fijo de 5 posts y el offset especificado.
    • Cada post recuperado es procesado para sustituir su contenido completo por solo el primer párrafo, utilizando la función getFirstParagraph.
  5. 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

  1. 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 archivo src/routes/+page.server.ts.
  2. Funciones y Hooks de Svelte:
    • onMount: Hook que se ejecuta cuando el componente se monta en el DOM.

Declaración de Variables

  1. posts: Almacena el listado actual de posts visibles en la página.
  2. offset: Representa la posición actual desde donde se cargarán más posts.
  3. isLoading: Indica si se está realizando una carga de posts para evitar múltiples solicitudes simultáneas.
  4. hasMorePosts: Determina si quedan más posts por cargar. Si es false, detiene las peticiones adicionales.

Funciones Principales

loadMorePosts

Esta función gestiona la lógica para cargar más posts:

  1. Condiciones Iniciales:
    • No realiza ninguna acción si isLoading es true o si hasMorePosts es false.
  2. Carga de Posts:
    • Marca isLoading como true y actualiza el offset 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.
    • Si no hay posts en la respuesta, establece hasMorePosts en false.
  3. 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.
  4. Finalización:
    • Marca isLoading como false al terminar la carga.

fetchPosts

Se encarga de realizar la solicitud a la API para recuperar los posts:

  1. Simulación de Retardo:
    • Usa setTimeout para simular un retraso en la carga (2 segundos).
  2. Solicitud HTTP:
    • Envía una petición POST a /api/blog con el offset en el cuerpo de la solicitud.
    • Si la respuesta es exitosa (status 200), devuelve la respuesta; en caso contrario, lanza un error.

onMount e Intersection Observer

onMount inicializa un IntersectionObserver que detecta cuándo el usuario llega al final de la página:

  1. Configuración:
    • Observa un elemento con el ID #load-more.
    • Si el elemento es visible y no se está cargando, llama a loadMorePosts.
  2. 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 de posts y renderiza el componente ArticleMain para cada post.
  • Cada elemento del array posts se pasa como una prop (post) al componente ArticleMain.

2. Indicador de Carga (Spinner)

{#if isLoading}
    <div class="loading">
        <div class="spinner"></div>
    </div>
{/if}
  • Si la variable isLoading es true, 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 es false, 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:

  1. error de SvelteKit, que se utiliza para lanzar errores HTTP personalizados.
  2. El tipo PageServerLoad que ya lo usamos anteriormente y que nos ayuda a tipar la función load para garantizar que seguimos el formato esperado por SvelteKit.
  3. La función getPostBySlugUrl, que recupera un post de la base de datos en función de su slugUrl.

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, utilizamos error(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 👋.

102 vistas

🐍 Sígueme en Twitter

Si te gusta el contenido que subo y no quieres perderte nada, sígueme en Twitter y te avisaré cada vez que cree contenido nuevo 💪
Luego ¡Te sigo!

Nos tomamos en serio tu privacidad

Utilizamos cookies propias y de terceros para recopilar y analizar datos sobre la interacción de los usuarios con cosasdedevs.com. Ver política de cookies.