Cómo usar la API de Hashnode con Astro y desplegarlo en Vercel

Cómo usar la API de Hashnode con Astro y desplegarlo en Vercel

Source: Dev.to

Introducción ## Requisitos previos ## Paso 1: Configurar tu blog en Hashnode ## Paso 2: Crear el proyecto con Astro ## Paso 3: Instalar dependencias necesarias ## Paso 4: Configurar variables de entorno ## Paso 5: Crear el cliente de la API de Hashnode ## Paso 6: Crear la página principal ## Paso 7: Crear la página de artículo individual ## Paso 8: Configurar Astro para producción ## Paso 9: Desplegar en Vercel ## Opción 1: Desde la interfaz de Vercel ## Opción 2: Desde la CLI de Vercel ## Paso 10: Configurar redepliegue automático ## Optimizaciones adicionales ## Añadir regeneración incremental ## Caché de datos ## Añadir sitemap ## Conclusión ## Recursos adicionales ¿Te gustaría tener tu propio sitio web con un diseño personalizado pero sin perder el excelente editor de Hashnode? En este artículo aprenderás a crear un blog con Astro que consume contenido directamente desde la API de Hashnode y cómo desplegarlo en Vercel. ¿Por qué esta combinación? Para encontrar tu Publication ID: Abre tu terminal y ejecuta: Selecciona las siguientes opciones: Crea un archivo .env en la raíz del proyecto: Crea el archivo src/lib/hashnode.ts: Edita src/pages/index.astro: Crea src/pages/post/[slug].astro: Instala marked para procesar Markdown: Edita astro.config.mjs: Sigue las instrucciones en pantalla. En la configuración, añade tus variables de entorno. Para que tu sitio se actualice automáticamente cuando publiques en Hashnode: Ahora, cada vez que publiques un artículo en Hashnode, Vercel reconstruirá tu sitio automáticamente. Edita astro.config.mjs: Instala el adaptador: Crea src/lib/cache.ts: Actualiza astro.config.mjs: Ahora tienes un blog ultrarrápido que combina lo mejor de tres mundos: Tu flujo de trabajo es simple: escribe en Hashnode, publica, y tu sitio se actualiza automáticamente. ¡Sin preocuparte por la infraestructura! ¿Tienes preguntas? Déjalas en los comentarios o contáctame en mis redes sociales. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse COMMAND_BLOCK: npm create astro@latest mi-blog-hashnode cd mi-blog-hashnode Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: npm create astro@latest mi-blog-hashnode cd mi-blog-hashnode COMMAND_BLOCK: npm create astro@latest mi-blog-hashnode cd mi-blog-hashnode COMMAND_BLOCK: npm install graphql-request graphql Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: npm install graphql-request graphql COMMAND_BLOCK: npm install graphql-request graphql CODE_BLOCK: HASHNODE_PUBLICATION_ID=tu-publication-id-aqui Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: HASHNODE_PUBLICATION_ID=tu-publication-id-aqui CODE_BLOCK: HASHNODE_PUBLICATION_ID=tu-publication-id-aqui COMMAND_BLOCK: import { GraphQLClient, gql } from 'graphql-request'; const endpoint = 'https://gql.hashnode.com'; const client = new GraphQLClient(endpoint); export interface Post { id: string; title: string; brief: string; slug: string; coverImage?: { url: string; }; content: { markdown: string; }; publishedAt: string; tags?: Array<{ name: string; slug: string; }>; author: { name: string; profilePicture?: string; }; } const GET_POSTS = gql` query GetPosts($host: String!, $first: Int!) { publication(host: $host) { posts(first: $first) { edges { node { id title brief slug coverImage { url } publishedAt tags { name slug } author { name profilePicture } } } } } } `; const GET_POST = gql` query GetPost($host: String!, $slug: String!) { publication(host: $host) { post(slug: $slug) { id title brief slug coverImage { url } content { markdown } publishedAt tags { name slug } author { name profilePicture } } } } `; export async function getPosts(host: string, first = 20): Promise<Post[]> { const data: any = await client.request(GET_POSTS, { host, first }); return data.publication.posts.edges.map((edge: any) => edge.node); } export async function getPost(host: string, slug: string): Promise<Post | null> { const data: any = await client.request(GET_POST, { host, slug }); return data.publication.post; } Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: import { GraphQLClient, gql } from 'graphql-request'; const endpoint = 'https://gql.hashnode.com'; const client = new GraphQLClient(endpoint); export interface Post { id: string; title: string; brief: string; slug: string; coverImage?: { url: string; }; content: { markdown: string; }; publishedAt: string; tags?: Array<{ name: string; slug: string; }>; author: { name: string; profilePicture?: string; }; } const GET_POSTS = gql` query GetPosts($host: String!, $first: Int!) { publication(host: $host) { posts(first: $first) { edges { node { id title brief slug coverImage { url } publishedAt tags { name slug } author { name profilePicture } } } } } } `; const GET_POST = gql` query GetPost($host: String!, $slug: String!) { publication(host: $host) { post(slug: $slug) { id title brief slug coverImage { url } content { markdown } publishedAt tags { name slug } author { name profilePicture } } } } `; export async function getPosts(host: string, first = 20): Promise<Post[]> { const data: any = await client.request(GET_POSTS, { host, first }); return data.publication.posts.edges.map((edge: any) => edge.node); } export async function getPost(host: string, slug: string): Promise<Post | null> { const data: any = await client.request(GET_POST, { host, slug }); return data.publication.post; } COMMAND_BLOCK: import { GraphQLClient, gql } from 'graphql-request'; const endpoint = 'https://gql.hashnode.com'; const client = new GraphQLClient(endpoint); export interface Post { id: string; title: string; brief: string; slug: string; coverImage?: { url: string; }; content: { markdown: string; }; publishedAt: string; tags?: Array<{ name: string; slug: string; }>; author: { name: string; profilePicture?: string; }; } const GET_POSTS = gql` query GetPosts($host: String!, $first: Int!) { publication(host: $host) { posts(first: $first) { edges { node { id title brief slug coverImage { url } publishedAt tags { name slug } author { name profilePicture } } } } } } `; const GET_POST = gql` query GetPost($host: String!, $slug: String!) { publication(host: $host) { post(slug: $slug) { id title brief slug coverImage { url } content { markdown } publishedAt tags { name slug } author { name profilePicture } } } } `; export async function getPosts(host: string, first = 20): Promise<Post[]> { const data: any = await client.request(GET_POSTS, { host, first }); return data.publication.posts.edges.map((edge: any) => edge.node); } export async function getPost(host: string, slug: string): Promise<Post | null> { const data: any = await client.request(GET_POST, { host, slug }); return data.publication.post; } COMMAND_BLOCK: --- import { getPosts } from '../lib/hashnode'; const publicationHost = 'tu-blog.hashnode.dev'; // Reemplaza con tu host de Hashnode const posts = await getPosts(publicationHost, 10); --- <!DOCTYPE html> <html lang="es"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Mi Blog</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; color: #333; background: #f5f5f5; } .container { max-width: 1200px; margin: 0 auto; padding: 2rem; } header { text-align: center; margin-bottom: 3rem; } h1 { font-size: 2.5rem; margin-bottom: 0.5rem; color: #2563eb; } .posts-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 2rem; } .post-card { background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); transition: transform 0.2s; text-decoration: none; color: inherit; display: block; } .post-card:hover { transform: translateY(-4px); box-shadow: 0 4px 16px rgba(0,0,0,0.15); } .post-image { width: 100%; height: 200px; object-fit: cover; } .post-content { padding: 1.5rem; } .post-title { font-size: 1.25rem; margin-bottom: 0.5rem; color: #1e293b; } .post-brief { color: #64748b; margin-bottom: 1rem; } .post-meta { display: flex; justify-content: space-between; align-items: center; font-size: 0.875rem; color: #94a3b8; } </style> </head> <body> <div class="container"> <header> <h1>Mi Blog</h1> <p>Artículos técnicos y tutoriales</p> </header> <div class="posts-grid"> {posts.map(post => ( <a href={`/post/${post.slug}`} class="post-card"> {post.coverImage && ( <img src={post.coverImage.url} alt={post.title} class="post-image" /> )} <div class="post-content"> <h2 class="post-title">{post.title}</h2> <p class="post-brief">{post.brief}</p> <div class="post-meta"> <span>{post.author.name}</span> <span>{new Date(post.publishedAt).toLocaleDateString('es-ES')}</span> </div> </div> </a> ))} </div> </div> </body> </html> Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: --- import { getPosts } from '../lib/hashnode'; const publicationHost = 'tu-blog.hashnode.dev'; // Reemplaza con tu host de Hashnode const posts = await getPosts(publicationHost, 10); --- <!DOCTYPE html> <html lang="es"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Mi Blog</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; color: #333; background: #f5f5f5; } .container { max-width: 1200px; margin: 0 auto; padding: 2rem; } header { text-align: center; margin-bottom: 3rem; } h1 { font-size: 2.5rem; margin-bottom: 0.5rem; color: #2563eb; } .posts-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 2rem; } .post-card { background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); transition: transform 0.2s; text-decoration: none; color: inherit; display: block; } .post-card:hover { transform: translateY(-4px); box-shadow: 0 4px 16px rgba(0,0,0,0.15); } .post-image { width: 100%; height: 200px; object-fit: cover; } .post-content { padding: 1.5rem; } .post-title { font-size: 1.25rem; margin-bottom: 0.5rem; color: #1e293b; } .post-brief { color: #64748b; margin-bottom: 1rem; } .post-meta { display: flex; justify-content: space-between; align-items: center; font-size: 0.875rem; color: #94a3b8; } </style> </head> <body> <div class="container"> <header> <h1>Mi Blog</h1> <p>Artículos técnicos y tutoriales</p> </header> <div class="posts-grid"> {posts.map(post => ( <a href={`/post/${post.slug}`} class="post-card"> {post.coverImage && ( <img src={post.coverImage.url} alt={post.title} class="post-image" /> )} <div class="post-content"> <h2 class="post-title">{post.title}</h2> <p class="post-brief">{post.brief}</p> <div class="post-meta"> <span>{post.author.name}</span> <span>{new Date(post.publishedAt).toLocaleDateString('es-ES')}</span> </div> </div> </a> ))} </div> </div> </body> </html> COMMAND_BLOCK: --- import { getPosts } from '../lib/hashnode'; const publicationHost = 'tu-blog.hashnode.dev'; // Reemplaza con tu host de Hashnode const posts = await getPosts(publicationHost, 10); --- <!DOCTYPE html> <html lang="es"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Mi Blog</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; color: #333; background: #f5f5f5; } .container { max-width: 1200px; margin: 0 auto; padding: 2rem; } header { text-align: center; margin-bottom: 3rem; } h1 { font-size: 2.5rem; margin-bottom: 0.5rem; color: #2563eb; } .posts-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 2rem; } .post-card { background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); transition: transform 0.2s; text-decoration: none; color: inherit; display: block; } .post-card:hover { transform: translateY(-4px); box-shadow: 0 4px 16px rgba(0,0,0,0.15); } .post-image { width: 100%; height: 200px; object-fit: cover; } .post-content { padding: 1.5rem; } .post-title { font-size: 1.25rem; margin-bottom: 0.5rem; color: #1e293b; } .post-brief { color: #64748b; margin-bottom: 1rem; } .post-meta { display: flex; justify-content: space-between; align-items: center; font-size: 0.875rem; color: #94a3b8; } </style> </head> <body> <div class="container"> <header> <h1>Mi Blog</h1> <p>Artículos técnicos y tutoriales</p> </header> <div class="posts-grid"> {posts.map(post => ( <a href={`/post/${post.slug}`} class="post-card"> {post.coverImage && ( <img src={post.coverImage.url} alt={post.title} class="post-image" /> )} <div class="post-content"> <h2 class="post-title">{post.title}</h2> <p class="post-brief">{post.brief}</p> <div class="post-meta"> <span>{post.author.name}</span> <span>{new Date(post.publishedAt).toLocaleDateString('es-ES')}</span> </div> </div> </a> ))} </div> </div> </body> </html> COMMAND_BLOCK: --- import { getPost, getPosts } from '../../lib/hashnode'; import { marked } from 'marked'; const { slug } = Astro.params; const publicationHost = 'tu-blog.hashnode.dev'; // Reemplaza con tu host if (!slug) { return Astro.redirect('/'); } const post = await getPost(publicationHost, slug); if (!post) { return Astro.redirect('/'); } const htmlContent = marked(post.content.markdown); export async function getStaticPaths() { const publicationHost = 'tu-blog.hashnode.dev'; const posts = await getPosts(publicationHost, 50); return posts.map(post => ({ params: { slug: post.slug } })); } --- <!DOCTYPE html> <html lang="es"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{post.title}</title> <meta name="description" content={post.brief}> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; color: #333; background: #f5f5f5; } .container { max-width: 800px; margin: 0 auto; padding: 2rem; } .back-link { display: inline-block; margin-bottom: 2rem; color: #2563eb; text-decoration: none; } .back-link:hover { text-decoration: underline; } article { background: white; border-radius: 8px; padding: 3rem; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } .cover-image { width: 100%; height: 400px; object-fit: cover; border-radius: 8px; margin-bottom: 2rem; } h1 { font-size: 2.5rem; margin-bottom: 1rem; color: #1e293b; } .meta { color: #64748b; margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 1px solid #e2e8f0; } .content { font-size: 1.125rem; line-height: 1.8; } .content h2 { margin-top: 2rem; margin-bottom: 1rem; color: #1e293b; } .content h3 { margin-top: 1.5rem; margin-bottom: 0.75rem; color: #334155; } .content p { margin-bottom: 1rem; } .content pre { background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 4px; overflow-x: auto; margin-bottom: 1rem; } .content code { background: #f1f5f9; padding: 0.2rem 0.4rem; border-radius: 3px; font-size: 0.9em; } .content pre code { background: none; padding: 0; } .content a { color: #2563eb; text-decoration: none; } .content a:hover { text-decoration: underline; } .content ul, .content ol { margin-bottom: 1rem; padding-left: 2rem; } .content li { margin-bottom: 0.5rem; } </style> </head> <body> <div class="container"> <a href="/" class="back-link">← Volver al inicio</a> <article> {post.coverImage && ( <img src={post.coverImage.url} alt={post.title} class="cover-image" /> )} <h1>{post.title}</h1> <div class="meta"> <p>Por {post.author.name} • {new Date(post.publishedAt).toLocaleDateString('es-ES', { year: 'numeric', month: 'long', day: 'numeric' })}</p> </div> <div class="content" set:html={htmlContent} /> </article> </div> </body> </html> Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: --- import { getPost, getPosts } from '../../lib/hashnode'; import { marked } from 'marked'; const { slug } = Astro.params; const publicationHost = 'tu-blog.hashnode.dev'; // Reemplaza con tu host if (!slug) { return Astro.redirect('/'); } const post = await getPost(publicationHost, slug); if (!post) { return Astro.redirect('/'); } const htmlContent = marked(post.content.markdown); export async function getStaticPaths() { const publicationHost = 'tu-blog.hashnode.dev'; const posts = await getPosts(publicationHost, 50); return posts.map(post => ({ params: { slug: post.slug } })); } --- <!DOCTYPE html> <html lang="es"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{post.title}</title> <meta name="description" content={post.brief}> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; color: #333; background: #f5f5f5; } .container { max-width: 800px; margin: 0 auto; padding: 2rem; } .back-link { display: inline-block; margin-bottom: 2rem; color: #2563eb; text-decoration: none; } .back-link:hover { text-decoration: underline; } article { background: white; border-radius: 8px; padding: 3rem; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } .cover-image { width: 100%; height: 400px; object-fit: cover; border-radius: 8px; margin-bottom: 2rem; } h1 { font-size: 2.5rem; margin-bottom: 1rem; color: #1e293b; } .meta { color: #64748b; margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 1px solid #e2e8f0; } .content { font-size: 1.125rem; line-height: 1.8; } .content h2 { margin-top: 2rem; margin-bottom: 1rem; color: #1e293b; } .content h3 { margin-top: 1.5rem; margin-bottom: 0.75rem; color: #334155; } .content p { margin-bottom: 1rem; } .content pre { background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 4px; overflow-x: auto; margin-bottom: 1rem; } .content code { background: #f1f5f9; padding: 0.2rem 0.4rem; border-radius: 3px; font-size: 0.9em; } .content pre code { background: none; padding: 0; } .content a { color: #2563eb; text-decoration: none; } .content a:hover { text-decoration: underline; } .content ul, .content ol { margin-bottom: 1rem; padding-left: 2rem; } .content li { margin-bottom: 0.5rem; } </style> </head> <body> <div class="container"> <a href="/" class="back-link">← Volver al inicio</a> <article> {post.coverImage && ( <img src={post.coverImage.url} alt={post.title} class="cover-image" /> )} <h1>{post.title}</h1> <div class="meta"> <p>Por {post.author.name} • {new Date(post.publishedAt).toLocaleDateString('es-ES', { year: 'numeric', month: 'long', day: 'numeric' })}</p> </div> <div class="content" set:html={htmlContent} /> </article> </div> </body> </html> COMMAND_BLOCK: --- import { getPost, getPosts } from '../../lib/hashnode'; import { marked } from 'marked'; const { slug } = Astro.params; const publicationHost = 'tu-blog.hashnode.dev'; // Reemplaza con tu host if (!slug) { return Astro.redirect('/'); } const post = await getPost(publicationHost, slug); if (!post) { return Astro.redirect('/'); } const htmlContent = marked(post.content.markdown); export async function getStaticPaths() { const publicationHost = 'tu-blog.hashnode.dev'; const posts = await getPosts(publicationHost, 50); return posts.map(post => ({ params: { slug: post.slug } })); } --- <!DOCTYPE html> <html lang="es"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{post.title}</title> <meta name="description" content={post.brief}> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; color: #333; background: #f5f5f5; } .container { max-width: 800px; margin: 0 auto; padding: 2rem; } .back-link { display: inline-block; margin-bottom: 2rem; color: #2563eb; text-decoration: none; } .back-link:hover { text-decoration: underline; } article { background: white; border-radius: 8px; padding: 3rem; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } .cover-image { width: 100%; height: 400px; object-fit: cover; border-radius: 8px; margin-bottom: 2rem; } h1 { font-size: 2.5rem; margin-bottom: 1rem; color: #1e293b; } .meta { color: #64748b; margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 1px solid #e2e8f0; } .content { font-size: 1.125rem; line-height: 1.8; } .content h2 { margin-top: 2rem; margin-bottom: 1rem; color: #1e293b; } .content h3 { margin-top: 1.5rem; margin-bottom: 0.75rem; color: #334155; } .content p { margin-bottom: 1rem; } .content pre { background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 4px; overflow-x: auto; margin-bottom: 1rem; } .content code { background: #f1f5f9; padding: 0.2rem 0.4rem; border-radius: 3px; font-size: 0.9em; } .content pre code { background: none; padding: 0; } .content a { color: #2563eb; text-decoration: none; } .content a:hover { text-decoration: underline; } .content ul, .content ol { margin-bottom: 1rem; padding-left: 2rem; } .content li { margin-bottom: 0.5rem; } </style> </head> <body> <div class="container"> <a href="/" class="back-link">← Volver al inicio</a> <article> {post.coverImage && ( <img src={post.coverImage.url} alt={post.title} class="cover-image" /> )} <h1>{post.title}</h1> <div class="meta"> <p>Por {post.author.name} • {new Date(post.publishedAt).toLocaleDateString('es-ES', { year: 'numeric', month: 'long', day: 'numeric' })}</p> </div> <div class="content" set:html={htmlContent} /> </article> </div> </body> </html> COMMAND_BLOCK: npm install marked Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: npm install marked COMMAND_BLOCK: npm install marked CODE_BLOCK: import { defineConfig } from 'astro/config'; export default defineConfig({ output: 'static', build: { inlineStylesheets: 'auto' } }); Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: import { defineConfig } from 'astro/config'; export default defineConfig({ output: 'static', build: { inlineStylesheets: 'auto' } }); CODE_BLOCK: import { defineConfig } from 'astro/config'; export default defineConfig({ output: 'static', build: { inlineStylesheets: 'auto' } }); COMMAND_BLOCK: npm install -g vercel vercel login vercel Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: npm install -g vercel vercel login vercel COMMAND_BLOCK: npm install -g vercel vercel login vercel CODE_BLOCK: export default defineConfig({ output: 'hybrid', adapter: vercel({ edgeMiddleware: true }) }); Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: export default defineConfig({ output: 'hybrid', adapter: vercel({ edgeMiddleware: true }) }); CODE_BLOCK: export default defineConfig({ output: 'hybrid', adapter: vercel({ edgeMiddleware: true }) }); COMMAND_BLOCK: npm install @astrojs/vercel Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: npm install @astrojs/vercel COMMAND_BLOCK: npm install @astrojs/vercel COMMAND_BLOCK: const cache = new Map(); const CACHE_DURATION = 5 * 60 * 1000; // 5 minutos export function getCached<T>(key: string): T | null { const cached = cache.get(key); if (!cached) return null; if (Date.now() - cached.timestamp > CACHE_DURATION) { cache.delete(key); return null; } return cached.data; } export function setCache<T>(key: string, data: T): void { cache.set(key, { data, timestamp: Date.now() }); } Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: const cache = new Map(); const CACHE_DURATION = 5 * 60 * 1000; // 5 minutos export function getCached<T>(key: string): T | null { const cached = cache.get(key); if (!cached) return null; if (Date.now() - cached.timestamp > CACHE_DURATION) { cache.delete(key); return null; } return cached.data; } export function setCache<T>(key: string, data: T): void { cache.set(key, { data, timestamp: Date.now() }); } COMMAND_BLOCK: const cache = new Map(); const CACHE_DURATION = 5 * 60 * 1000; // 5 minutos export function getCached<T>(key: string): T | null { const cached = cache.get(key); if (!cached) return null; if (Date.now() - cached.timestamp > CACHE_DURATION) { cache.delete(key); return null; } return cached.data; } export function setCache<T>(key: string, data: T): void { cache.set(key, { data, timestamp: Date.now() }); } COMMAND_BLOCK: npm install @astrojs/sitemap Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: npm install @astrojs/sitemap COMMAND_BLOCK: npm install @astrojs/sitemap CODE_BLOCK: import sitemap from '@astrojs/sitemap'; export default defineConfig({ site: 'https://tu-sitio.vercel.app', integrations: [sitemap()] }); Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: import sitemap from '@astrojs/sitemap'; export default defineConfig({ site: 'https://tu-sitio.vercel.app', integrations: [sitemap()] }); CODE_BLOCK: import sitemap from '@astrojs/sitemap'; export default defineConfig({ site: 'https://tu-sitio.vercel.app', integrations: [sitemap()] }); - Hashnode: Editor potente con Markdown, sintaxis de código, imágenes y SEO integrado - Astro: Framework ultrarrápido con excelente rendimiento y SEO - Vercel: Despliegue automático y CDN global sin configuración - Node.js 18 o superior instalado - Una cuenta en Hashnode - Una cuenta en Vercel (gratis) - Conocimientos básicos de JavaScript/TypeScript - Crea una cuenta en Hashnode - Configura tu blog en Hashnode (puedes usar un subdominio gratuito) - Escribe algunos artículos de prueba - Obtén tu Publication ID desde la configuración de tu blog - Ve a tu dashboard de Hashnode - Entra en la configuración de tu publicación - Busca el ID en la URL o en la sección de API - Template: Empty - TypeScript: Yes - Install dependencies: Yes - Git repository: Yes - Sube tu código a GitHub - Ve a Vercel - Haz clic en "Add New Project" - Importa tu repositorio de GitHub - Vercel detectará automáticamente que es un proyecto Astro - Añade las variables de entorno: HASHNODE_PUBLICATION_ID: Tu ID de publicación - HASHNODE_PUBLICATION_ID: Tu ID de publicación - Haz clic en "Deploy" - HASHNODE_PUBLICATION_ID: Tu ID de publicación - En Vercel, ve a tu proyecto → Settings → Git - Copia el "Deploy Hook URL" - En Hashnode, ve a tu blog → Settings → Webhooks - Añade el Deploy Hook URL de Vercel - Selecciona el evento "post.published" - Escribes en el excelente editor de Hashnode - Diseñas tu sitio con total libertad en Astro - Despliegas automáticamente en Vercel con CDN global - Documentación de Hashnode API - Documentación de Astro - Documentación de Vercel - GraphQL Playground de Hashnode