Tools
Cómo usar la API de Hashnode con Astro y desplegarlo en Vercel
2025-12-22
0 views
admin
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 ? It will become hidden in your post, but will still be visible via the comment's permalink. as well , this person and/or 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 create astro@latest mi-blog-hashnode cd mi-blog-hashnode COMMAND_BLOCK: npm install graphql-request graphql COMMAND_BLOCK: npm install graphql-request graphql COMMAND_BLOCK: npm install graphql-request graphql CODE_BLOCK: HASHNODE_PUBLICATION_ID=tu-publication-id-aqui 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; } 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> 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> 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 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' } }); 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 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 }) }); 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 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() }); } 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 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()] }); 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
toolsutilitiessecurity toolshashnodeastrodesplegarlovercelintroduccirce