Parte 1: Cargar posts en la página de inicio y detalle del post con Svelte 5 y Sveltekit
En esta sección del tutorial, vamos a integrar la funcionalidad para cargar dinámicamente los posts desde una base de datos en la página de inicio de nuestro blog.
Además, crearemos una página de detalle para mostrar información específica de cada post cuando los usuarios hagan clic en ellos. Esto nos permitirá entender cómo manejar rutas dinámicas y obtener datos específicos utilizando las herramientas nativas de SvelteKit.
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 👷
Añadir nuevos estilos y assets
El primer paso a realizar es añadir nuevos estilos al archivo, app.css
donde almacenamos los estilos generales que pueden ser utilizados en toda la aplicación. Para ello, abrimos el archivo src\app.css
y añadimos las siguientes líneas:
a {
color: #347cbb;
}
a:hover {
color: #FFAA00;
}
.main-container {
max-width: 800px;
margin: 0 auto;
text-align: center;
}
.post {
padding: 0 1rem;
}
.image-post img {
max-width: 100%;
object-fit: cover;
}
.content-post {
margin: 1rem 0;
max-width: 750px;
text-align: justify;
padding: 0 1rem;
}
.content-post p {
margin-bottom: 1rem;
}
.separator {
border-bottom: 1px solid #9d9d9d;
margin: 1rem 0;
}
Básicamente, hemos definido unos colores para los links, un contenedor principal para todas nuestras páginas, estilos para los artículos de nuestro blog y un separador que añadirá una línea gris horizontal donde la necesitemos.
También vamos a añadir una nueva imagen dentro de src\lib\assets
que utilizaremos como imagen por defecto cuando uno de nuestros usuarios no tenga un avatar.
Añadir campo para almacenar el avatar en la tabla usuarios
En el paso anterior se me olvidó añadir el campo donde almacenar el avatar del usuario, así que tendremos que editar el esquema de la base de datos. Abrimos el archivo src\lib\server\db\schema.ts
y añadimos el campo avatar que será de tipo varchar
y con una longitud máxima de 255 caracteres. Al igual que para los posts, en él guardaremos la ruta donde está almacenado.
export const usersTable = pgTable("users", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
username: varchar({ length: 255 }).notNull(),
password: varchar({ length: 255 }).notNull(),
email: varchar({ length: 255 }).notNull().unique(),
createdAt: timestamp("created_at", {
withTimezone: true,
mode: "date"
}).defaultNow().notNull(),
isActive: boolean("is_active").default(true).notNull(),
isAdmin: boolean("is_admin").default(false).notNull(),
avatar: varchar('avatar', { length: 255 }),
});
Para añadir el campo en la tabla, debemos volver a lanzar el siguiente comando que subirá el cambio realizado en el esquema:
npx drizzle-kit push
Cargar artículos en la página principal
La idea es que, al acceder a la página principal, se carguen automáticamente los 5 últimos posts publicados que no estén en borradores.
Implementaremos una funcionalidad de scroll infinito para que, al llegar al final de la página, se carguen los siguientes 5 posts, continuando así hasta que no haya más artículos disponibles.
Cada post mostrará su imagen destacada, título y el primer párrafo, junto con botones para compartir en Twitter y Facebook. Además, incluiremos el avatar del autor, su nombre de usuario y la fecha de publicación de cada post.
Modificar el archivo layout.ts
Vamos a editar el archivo src/routes/+layout.svelte
, que, como recordarás, es donde definimos todos los elementos comunes de nuestra web.
En este archivo, envolveremos el contenido de los componentes hijos (children
) dentro de una etiqueta <main>
con la clase main-container
.
Esta clase, que creamos anteriormente, asegura que todos los componentes hijos de nuestra aplicación compartan el mismo tamaño y estilo de contenedor.
<main class="main-container">
{@render children()}
</main>
Funciones para trabajar con nuestros artículos
Para mantener nuestro proyecto organizado y facilitar la reutilización de funciones, vamos a crear un archivo llamado src/lib/server/db/posts.ts
, donde almacenaremos todas las funciones relacionadas con los posts y la base de datos.
Antes de continuar, necesitamos renombrar el archivo src/lib/server/db/index.ts
a src/lib/server/db/db.ts
. Aunque inicialmente lo copié así de la documentación, considero que nombrarlo db
es más intuitivo y refleja mejor su propósito.
Una vez creado el archivo src/lib/server/db/posts.ts
añadiremos el siguiente contenido:
import { eq, and, type InferSelectModel, desc } from 'drizzle-orm';
import { postsTable, usersTable } from './schema';
import { db } from './db';
export type PostWithUser = Pick<InferSelectModel<typeof postsTable>,
'title' | 'content' | 'createdAt' | 'imageHeader' | 'slugUrl'
> & {
username: string;
avatar: string | null;
};
export async function getPostBySlugUrl(slugUrl: string): Promise<PostWithUser | undefined> {
const result = await db
.select({
title: postsTable.title,
content: postsTable.content,
createdAt: postsTable.createdAt,
imageHeader: postsTable.imageHeader,
slugUrl: postsTable.slugUrl,
username: usersTable.username,
avatar: usersTable.avatar,
})
.from(postsTable)
.innerJoin(usersTable, eq(postsTable.userId, usersTable.id))
.where(and(eq(postsTable.isDraft, false), eq(postsTable.slugUrl, slugUrl)));
return result[0];
}
export async function getPosts(limit: number = 5, offset: number = 0): Promise<PostWithUser[]> {
return await db.select({
title: postsTable.title,
content: postsTable.content,
createdAt: postsTable.createdAt,
imageHeader: postsTable.imageHeader,
slugUrl: postsTable.slugUrl,
username: usersTable.username,
avatar: usersTable.avatar,
}).from(postsTable)
.innerJoin(usersTable, eq(postsTable.userId, usersTable.id))
.where(eq(postsTable.isDraft, false)).orderBy(desc(postsTable.createdAt)).limit(limit).offset(offset);
}
Con este código nos encargamos de recuperar los posts de la base de datos según nuestras necesidades específicas.
- Importaciones:
- Importamos funciones clave de Drizzle ORM como operadores y tipos necesarios para construir nuestras consultas.
- Cargamos los esquemas de las tablas
usersTable
ypostsTable
, que representan la estructura de las tablas en nuestra base de datos. - Importamos la conexión a la base de datos desde el archivo
db
.
- Definición del tipo
PostWithUser
:- Creamos un tipo que combina campos específicos de
postsTable
(title
,content
,createdAt
,imageHeader
,slugUrl
) con los camposusername
yavatar
provenientes deusersTable
. Este tipo nos permite estructurar y manejar datos que combinan información de ambas tablas.
- Creamos un tipo que combina campos específicos de
- Función
getPostBySlugUrl
:- Recupera un único post identificándolo por su
slugUrl
. - Comprueba que el post no esté en borradores (
isDraft = false
). - Selecciona y devuelve los campos necesarios tanto del post como del usuario relacionado.
- Recupera un único post identificándolo por su
- Función
getPosts
:- Recupera una lista de posts paginados con soporte para límites (
limit
) y desplazamiento (offset
), útil para implementar funcionalidades como scroll infinito. - Asegura que los posts no sean borradores y los ordena por fecha de creación en orden descendente.
- Devuelve los campos necesarios de cada post junto con los datos del usuario que lo publicó.
- Recupera una lista de posts paginados con soporte para límites (
Utilidades para el contenido HTML
Para mostrar únicamente el primer párrafo de cada post, añadiremos una función dedicada a esta tarea. Para organizar mejor el proyecto, crearemos una carpeta llamada utils
dentro de src\lib
. En esta carpeta, generaremos un archivo llamado html.ts
, donde almacenaremos esta función y, en el futuro, otras funciones relacionadas con la manipulación de HTML. El contenido inicial del archivo será el siguiente:
export function getFirstParagraph(htmlContent: string): string {
const match = htmlContent.match(/<p>.*?<\/p>/s);
return match ? match[0] : '';
}
Recuperar los últimos 5 posts para la página principal
En un proyecto con Svelte 5 y SvelteKit, para realizar acciones del lado del servidor, como recuperar los posts, utilizamos un tipo especial de archivos llamados +page.server.ts
. Estos archivos se ubican en la ruta correspondiente a la página donde necesitamos realizar dichas acciones y permiten generar información que luego podemos acceder desde los archivos +page.svelte
.
Dado que ahora necesitamos esta funcionalidad para la página principal, crearemos el archivo en la raíz de la carpeta routes
, con la siguiente ruta: src\routes\+page.server.ts
. El contenido inicial de este archivo será:
import type { PageServerLoad } from './$types';
import { getPosts } from '$lib/server/db/posts';
import { getFirstParagraph } from '$lib/utils/html';
export const load: PageServerLoad = async () => {
const posts = await getPosts();
return {
posts: posts.map((post) => {
post.content = getFirstParagraph(post.content);
return post;
})
}
};
Este código define la función load
, que se ejecuta en el servidor para cargar datos y enviarlos a la página +page.svelte
. En ella usamos la función creamos anteriormente para recuperar los últimos posts y después utilizamos la función map
de JavaScript para sustituir el contenido de post.content
por el primer párrafo.
Crear un componente para los artículos de la página principal
Dado que los artículos de la página principal y los que se mostrarán en detalle tendrán formatos diferentes, vamos a separarlos en dos componentes independientes. Sin embargo, ambos compartirán una parte en común: el pie del artículo. Para mantener el código organizado y reutilizable, crearemos un componente específico para esta sección compartida.
Ya que en el pie de página vamos a mostrar dos iconos para el botón de compartir por Twitter y por Facebook, vamos a instalar la librería Iconify
que nos permite acceder a miles de iconos. Para instalar la versión para Svelte
, lanzaremos el siguiente comando desde la terminal:
npm install --save-dev @iconify/svelte
Ahora vamos a crear el componente para el pie de los artículos. Para ello, añadiremos una nueva carpeta llamada Article
dentro de src\lib\components
. Dentro de esta carpeta, crearemos un archivo llamado ArticleFooter.svelte
, que contendrá la lógica necesaria para ser utilizado posteriormente en ambos componentes de artículos. El contenido inicial del archivo será el siguiente:
<script lang="ts">
import { page } from "$app/stores";
import Icon from "@iconify/svelte";
import defaultAvatar from '$assets/default-avatar.jpg';
interface ArticleFooterProps {
slug: string;
username: string;
createdAt: Date;
avatar: string | null;
}
let { slug, username, createdAt, avatar }: ArticleFooterProps = $props();
const postUrl = encodeURIComponent(`${$page.url.origin}/blog/${slug}`)
const urlFacebook = `https://www.facebook.com/sharer/sharer.php?u=${postUrl}`;
const urlTwitter = `https://twitter.com/intent/tweet?text=${postUrl}`;
</script>
<div class="separator"></div>
<div class="footer-post">
<div class="footer-post-content-user">
<div><img src={avatar || defaultAvatar} alt="Avatar" /></div>
<div class="footer-post-content-user-info">
<div><strong>{username}</strong></div>
<div>
{createdAt.toLocaleDateString('en-US', {
day: '2-digit',
month: 'short',
year: 'numeric'
})}
</div>
</div>
</div>
<div class="footer-post-content-social">
<a href={urlTwitter} target="_blank" rel="noopener noreferrer">
<Icon icon="entypo-social:twitter-with-circle" width={'35'} />
</a>
<a href={urlFacebook} target="_blank" rel="noopener noreferrer">
<Icon icon="entypo-social:facebook-with-circle" width={'35'} />
</a>
</div>
</div>
<style>
.footer-post {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2.5rem;
}
.footer-post img {
width: 35px;
height: 35px;
cursor: pointer;
border-radius: 50%;
object-fit: cover;
}
.footer-post-content-user {
display: flex;
gap: 0.7rem;
align-items: center;
}
.footer-post-content-user-info {
display: flex;
text-align: left;
flex-direction: column;
font-size: 0.7rem;
}
</style>
El código realiza las siguientes acciones:
- Importaciones necesarias:
page
de SvelteKit: Lo usamos para obtener información sobre la URL actual de la página y construir la ruta completa del post.Icon
de la libreríaIconify
: Permite añadir iconos fácilmente.defaultAvatar
: Una imagen predeterminada que se usará si el usuario no tiene un avatar asignado.
- Interfaz de validación:
Creamos la interfazArticleFooterProps
para asegurarnos de que las propiedades del componente (slug
,username
,createdAt
yavatar
) cumplen con los tipos esperados. Esto ayuda a evitar errores y facilita la lectura del código. Vas a ver que estas interfaces las utilizaremos a menudo en este tutorial. - Obtención de las propiedades:
Recuperamos las propiedades del componente usando$props()
y las desestructuramos para trabajar más cómodamente con ellas. - Construcción de las URLs de redes sociales:
- Creamos
postUrl
, una versión codificada de la URL del post para asegurar que sea válida al compartirla. - A partir de
postUrl
, construimos las URLs específicas para compartir en Facebook y Twitter (urlFacebook
yurlTwitter
).
- Creamos
- Construcción del HTML:
- El pie del artículo incluye dos secciones principales:
- Información del autor: Se muestra el avatar del usuario (o la imagen predeterminada) y el nombre del autor. Además, formateamos la fecha (
createdAt
) en un formato legible utilizandotoLocaleDateString
con opciones específicas. - Botones para compartir: Creamos enlaces que abren las redes sociales en una nueva pestaña, con iconos de Facebook y Twitter personalizados desde Iconify.
- Información del autor: Se muestra el avatar del usuario (o la imagen predeterminada) y el nombre del autor. Además, formateamos la fecha (
- El pie del artículo incluye dos secciones principales:
- Uso de Iconify:
Los iconos se seleccionan mediante el atributoicon
, especificando el nombre desde la librería Iconify. Puedes consultar su web para explorar y seleccionar otros iconos. - Estilos del componente:
- Diseñamos el pie del artículo para que sea visualmente atractivo, con un diseño flexible usando
flexbox
. - Ajustamos el tamaño del avatar, lo redondeamos, y aseguramos que se recorte correctamente con
object-fit: cover
. - La información del usuario y los botones de redes sociales están distribuidos de forma clara y con espaciado adecuado.
- Diseñamos el pie del artículo para que sea visualmente atractivo, con un diseño flexible usando
El siguiente paso es crear el archivo ArticleMain.svelte
. Este lo crearemos también dentro de src\lib\components\Article
y tendrá 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">
<a href={`/blog/${post.slugUrl}`}>
<img src={post.imageHeader} alt="" />
</a>
</div>
<div class="title-post">
<a href={`/blog/${post.slugUrl}`}>
<h2>{post.title}</h2>
</a>
</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 h2 {
margin-top: 1rem;
font-size: 2.5rem;
}
</style>
En este código, creamos un componente para mostrar un artículo del blog, incorporando su imagen, título, contenido y un pie de artículo personalizado. A continuación, te explico cada parte del código:
- Importaciones esenciales:
- Importamos el componente
ArticleFooter
, que ya definimos previamente, para mostrar la información del autor y las opciones de compartir el post. - Importamos el tipo
PostWithUser
desde la carpetasrc\lib\server\db\posts.ts
. Esto nos permite usarlo como tipo de las propiedades que recibe el componente, asegurando que los datos sean consistentes y estén bien definidos.
- Importamos el componente
- Propiedades del componente:
Utilizamos$props()
para recibir la información del artículo a través de la variablepost
, que está tipada comoPostWithUser
. Esto ayuda a que el código sea más robusto y comprensible. - Construcción del HTML:
- Imagen del artículo: Mostramos la imagen de encabezado del post (
post.imageHeader
) como un enlace hacia la página del artículo. - Título del artículo: Incluimos un encabezado
<h2>
con el título del post (post.title
), también enlazado a su URL. - Contenido del artículo: Usamos la directiva
{@html}
para renderizar el contenido HTML del post, lo que permite mostrar el primer párrafo directamente desde los datos del artículo. - Pie del artículo: Integramos el componente
ArticleFooter
, pasando las propiedades necesarias (slug
,username
,createdAt
yavatar
) para completar su funcionalidad.
- Imagen del artículo: Mostramos la imagen de encabezado del post (
- Estilos personalizados:
- Agregamos un estilo específico para el título del artículo, ajustando el margen superior y el tamaño de fuente para destacar visualmente el encabezado.
En este paso, al igual que en el tutorial anterior, centralizamos las exportaciones de los componentes en el archivo src/lib/components/index.ts
.
En el archivo index.ts
, añadimos las siguientes líneas:
export {default as Header} from '$components/layout/Header.svelte';
export {default as ArticleMain} from '$components/Article/ArticleMain.svelte';
export {default as ArticleFooter} from '$components/Article/ArticleFooter.svelte';
Puedes continuar la siguiente parte de este tutorial pulsando aquí:
https://cosasdedevs.com/posts/parte-2-cargar-posts-pagina-inicio-detalle-post-svelte-5-sveltekit/
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 👋.