Proyecto Final: Mi Web Profesional con Astro
1. Qué vas a construir #
Un portfolio bilingüe con listado y detalle de proyectos Markdown/MDX, una valoración React, Tailwind 4 y un formulario con validación de servidor. Las páginas se generan estáticamente; únicamente el endpoint de contacto se ejecuta bajo demanda. La demostración valida solicitudes: no envía correos ni guarda datos.
Descarga el proyecto completo de referencia. Incluye los mismos archivos de esta lección y package-lock.json; descomprímelo, utiliza Node 24 y ejecuta npm ci, npm run check y npm run dev.
Si prefieres construirlo desde cero, crea un proyecto Minimal con npm create astro@latest mi-portfolio, entra en la carpeta y sustituye o añade los archivos siguientes. Elimina la página inicial del asistente si entra en conflicto con la nuestra.
2. Fijar versiones y configurar Astro #
El package.json fija las versiones utilizadas en la referencia. Ejecuta npm install después de crearlo. Conserva el lockfile; las siguientes instalaciones se harán con npm ci. La aplicación de esta lección usa Node para practicar; al final cambiamos al adaptador de Vercel para publicarla allí.
📄 package.json
1{ 2 "name": "portfolio-astro-7", 3 "private": true, 4 "type": "module", 5 "scripts": { 6 "dev": "astro dev", 7 "check": "astro check", 8 "build": "astro build", 9 "preview": "astro preview", 10 "start": "node ./dist/server/entry.mjs" 11 }, 12 "engines": { "node": ">=22.23.4" }, 13 "dependencies": { 14 "astro": "7.4.2", 15 "@astrojs/mdx": "8.1.2", 16 "@astrojs/node": "11.1.9", 17 "@astrojs/react": "7.1.2", 18 "@astrojs/vercel": "11.0.17", 19 "@tailwindcss/vite": "4.3.3", 20 "tailwindcss": "4.3.3", 21 "react": "19.2.8", 22 "react-dom": "19.2.8" 23 }, 24 "devDependencies": { 25 "@astrojs/check": "0.9.12", 26 "@types/react": "19.2.17", 27 "@types/react-dom": "19.2.3", 28 "typescript": "5.9.3" 29 } 30}
📄 .nvmrc
124
📄 .gitignore
1node_modules/ 2dist/ 3.astro/ 4.vercel/ 5.env 6.env.* 7!.env.example
📄 astro.config.mjs
1import { defineConfig } from 'astro/config'; 2import mdx from '@astrojs/mdx'; 3import react from '@astrojs/react'; 4import node from '@astrojs/node'; 5import tailwindcss from '@tailwindcss/vite'; 6 7export default defineConfig({ 8 adapter: node({ mode: 'standalone' }), 9 security: { allowedDomains: [{ hostname: 'localhost' }, { hostname: '127.0.0.1' }] }, 10 integrations: [mdx(), react()], 11 vite: { plugins: [tailwindcss()] }, 12 i18n: { 13 locales: ['es', 'en'], 14 defaultLocale: 'es', 15 routing: { prefixDefaultLocale: true, redirectToDefaultLocale: false }, 16 }, 17});
📄 tsconfig.json
1{ 2 "extends": "astro/tsconfigs/strict", 3 "include": [".astro/types.d.ts", "**/*"], 4 "exclude": ["dist"], 5 "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "react" } 6}
output conserva el valor estático predeterminado. El adaptador hace posible el endpoint marcado con prerender = false. Los hosts locales se declaran en security.allowedDomains para que el servidor reconstruya correctamente el origen de las peticiones. Al desplegar, añade el hostname real de tu web a esa lista; no autorices cualquier dominio. Ambos idiomas tienen prefijo; desactivamos la redirección automática para definir nuestra propia portada /.
3. Estilos, traducciones y layout #
Tailwind 4 se integra mediante Vite y se importa desde la hoja global. Las claves de traducción son iguales en ambos idiomas. ClientRouter permite navegar conservando elementos cuando lo indiquemos; también añade su propio JavaScript al navegador.
📄 src/styles/global.css
1@import "tailwindcss"; 2 3@theme { 4 --color-marca: #4338ca; 5} 6 7@layer base { 8 body { margin: 0; font-family: system-ui, sans-serif; } 9 a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible { 10 outline: 3px solid #4338ca; 11 outline-offset: 3px; 12 } 13 article h2 { margin-block: 1.5rem .5rem; font-size: 1.5rem; font-weight: 700; } 14 article p { margin-block: 1rem; } 15}
📄 src/i18n/ui.ts
1export const locales = ['es', 'en'] as const; 2export type Locale = (typeof locales)[number]; 3export const messages = { 4 es: { 5 home: 'Mis proyectos', about: 'Sobre mí', contact: 'Contacto', 6 intro: 'Portfolio de proyectos de informática.', 7 bio: 'Aprendo desarrollo web creando proyectos con Astro.', 8 back: 'Volver al listado', name: 'Nombre', email: 'Correo electrónico', 9 message: 'Mensaje', send: 'Enviar', rating: 'Valora este proyecto', 10 }, 11 en: { 12 home: 'My projects', about: 'About me', contact: 'Contact', 13 intro: 'A portfolio of computing projects.', 14 bio: 'I learn web development by building projects with Astro.', 15 back: 'Back to projects', name: 'Name', email: 'Email address', 16 message: 'Message', send: 'Send', rating: 'Rate this project', 17 }, 18}; 19export function getLocale(value: string | undefined): Locale { 20 return value === 'en' ? 'en' : 'es'; 21}
📄 src/layouts/BaseLayout.astro
1--- 2import '../styles/global.css'; 3import { ClientRouter } from 'astro:transitions'; 4import { messages, type Locale } from '../i18n/ui'; 5interface Props { title: string; lang: Locale } 6const { title, lang } = Astro.props; 7const t = messages[lang]; 8--- 9<!doctype html> 10<html lang={lang}> 11 <head> 12 <meta charset="UTF-8" /> 13 <meta name="viewport" content="width=device-width, initial-scale=1" /> 14 <meta name="description" content={t.intro} /> 15 <title>{title} · Portfolio Astro</title> 16 <ClientRouter /> 17 </head> 18 <body class="bg-gray-50 text-gray-900"> 19 <a href="#contenido" class="sr-only focus:not-sr-only">{lang === 'es' ? 'Ir al contenido' : 'Skip to content'}</a> 20 <header class="bg-marca p-4 text-white"> 21 <nav aria-label={lang === 'es' ? 'Principal' : 'Main'} class="mx-auto flex max-w-4xl flex-wrap gap-4"> 22 <a href={`/${lang}/`}>{t.home}</a> 23 <a href={`/${lang}/about/`}>{t.about}</a> 24 <a href={`/${lang}/contact/`}>{t.contact}</a> 25 <a href="/es/" lang="es" aria-label="Español">ES</a> 26 <a href="/en/" lang="en" aria-label="English">EN</a> 27 </nav> 28 </header> 29 <main id="contenido" class="mx-auto max-w-4xl p-6"><slot /></main> 30 <footer class="p-6 text-center">© {new Date().getFullYear()} · Astro 7</footer> 31 </body> 32</html>
4. Colección y documentos #
El loader incorpora .md y .mdx; lang permite filtrar cada listado. Para traducir un proyecto crea una entrada por idioma, con su propio ID y cuerpo. Los dos documentos siguientes muestran ambos formatos.
📄 src/content.config.ts
1import { defineCollection } from 'astro:content'; 2import { glob } from 'astro/loaders'; 3import { z } from 'astro/zod'; 4 5const projects = defineCollection({ 6 loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/projects' }), 7 schema: z.object({ 8 title: z.string(), 9 date: z.coerce.date(), 10 description: z.string(), 11 tech: z.array(z.string()), 12 lang: z.enum(['es', 'en']), 13 }), 14}); 15export const collections = { projects };
📄 src/content/projects/blog-es.md
1--- 2title: "Mi blog" 3date: "2026-10-29" 4description: "Un blog de apuntes con contenido tipado." 5tech: ["Astro", "Markdown", "Tailwind"] 6lang: "es" 7--- 8 9## Lo que he construido 10 11Este blog valida sus metadatos y genera páginas a partir de archivos Markdown. 12 13## Lo que he aprendido 14 15A separar contenido, componentes y datos de configuración.
📄 src/content/projects/blog-en.mdx
1--- 2title: "My blog" 3date: "2026-10-29" 4description: "A study blog with typed content." 5tech: ["Astro", "MDX", "Tailwind"] 6lang: "en" 7--- 8 9import Counter from '../../components/Counter.astro'; 10 11## What I built 12 13This MDX page imports an Astro component. Try its counter: 14 15<Counter start={5} />
📄 src/components/Counter.astro
1--- 2interface Props { start?: number } 3const { start = 0 } = Astro.props; 4--- 5<astro-counter data-start={start}> 6 <button type="button">Contador: {start}</button> 7</astro-counter> 8<script> 9 class AstroCounter extends HTMLElement { 10 connectedCallback() { 11 const button = this.querySelector('button'); 12 if (!button || button.dataset.ready) return; 13 button.dataset.ready = 'true'; 14 let count = Number(this.dataset.start ?? 0); 15 button.addEventListener('click', () => { 16 count += 1; 17 button.textContent = `Contador: ${count}`; 18 }); 19 } 20 } 21 if (!customElements.get('astro-counter')) { 22 customElements.define('astro-counter', AstroCounter); 23 } 24</script>
5. Una isla React accesible #
Cada página de detalle monta una valoración independiente. Utiliza radios nativos para permitir teclado y lectores de pantalla; el estado permanece durante la visita y no se guarda en una base de datos. client:visible permite hidratarla al entrar en pantalla.
📄 src/components/Rating.tsx
1import { useId, useState } from 'react'; 2 3export default function Rating({ label }: { label: string }) { 4 const [value, setValue] = useState(0); 5 const id = useId(); 6 return ( 7 <fieldset className="my-6 rounded border p-4"> 8 <legend>{label}</legend> 9 <div className="flex flex-wrap gap-3"> 10 {[1, 2, 3, 4, 5].map(stars => ( 11 <label key={stars} className="cursor-pointer text-2xl"> 12 <input className="mr-1" type="radio" name={id} value={stars} 13 checked={value === stars} onChange={() => setValue(stars)} 14 aria-label={`${stars} / 5`} /> 15 <span aria-hidden="true" className={stars <= value ? 'text-amber-600' : 'text-gray-500'}>★</span> 16 </label> 17 ))} 18 </div> 19 <p role="status">{value} / 5</p> 20 </fieldset> 21 ); 22}
6. Portada, listado, detalle y página personal #
getStaticPaths() genera las combinaciones existentes; el detalle obtiene el cuerpo mediante render(project). Las rutas de los proyectos utilizan un parámetro rest para admitir IDs con subcarpetas.
📄 src/pages/index.astro
1--- 2return Astro.redirect('/es/'); 3---
📄 src/pages/[lang]/index.astro
1--- 2import type { GetStaticPaths } from 'astro'; 3import { getCollection } from 'astro:content'; 4import BaseLayout from '../../layouts/BaseLayout.astro'; 5import { locales, getLocale, messages } from '../../i18n/ui'; 6 7export const getStaticPaths = (() => locales.map(lang => ({ params: { lang } }))) satisfies GetStaticPaths; 8const lang = getLocale(Astro.params.lang); 9const t = messages[lang]; 10const projects = (await getCollection('projects', ({ data }) => data.lang === lang)) 11 .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf()); 12--- 13<BaseLayout title={t.home} lang={lang}> 14 <h1 class="mb-6 text-3xl font-bold">{t.home}</h1> 15 <div class="grid gap-6 md:grid-cols-2"> 16 {projects.map(project => ( 17 <article class="rounded-xl bg-white p-6 shadow-sm"> 18 <h2 class="text-xl font-semibold"><a href={`/${lang}/projects/${project.id}/`}>{project.data.title}</a></h2> 19 <p>{project.data.description}</p> 20 <p class="mt-2 text-sm">{project.data.tech.join(', ')}</p> 21 </article> 22 ))} 23 </div> 24</BaseLayout>
📄 src/pages/[lang]/projects/[...id].astro
1--- 2import type { GetStaticPaths } from 'astro'; 3import { getCollection, render, type CollectionEntry } from 'astro:content'; 4import BaseLayout from '../../../layouts/BaseLayout.astro'; 5import Rating from '../../../components/Rating'; 6import { messages, type Locale } from '../../../i18n/ui'; 7 8export const getStaticPaths = (async () => { 9 const projects = await getCollection('projects'); 10 return projects.map(project => ({ 11 params: { lang: project.data.lang, id: project.id }, 12 props: { project }, 13 })); 14}) satisfies GetStaticPaths; 15type Props = { project: CollectionEntry<'projects'> }; 16const { project } = Astro.props; 17const lang: Locale = project.data.lang; 18const { Content } = await render(project); 19--- 20<BaseLayout title={project.data.title} lang={lang}> 21 <a class="underline" href={`/${lang}/`}>{messages[lang].back}</a> 22 <article> 23 <h1 class="mt-6 text-3xl font-bold">{project.data.title}</h1> 24 <Content /> 25 </article> 26 <Rating label={messages[lang].rating} client:visible /> 27</BaseLayout>
📄 src/pages/[lang]/about.astro
1--- 2import type { GetStaticPaths } from 'astro'; 3import BaseLayout from '../../layouts/BaseLayout.astro'; 4import { locales, getLocale, messages } from '../../i18n/ui'; 5export const getStaticPaths = (() => locales.map(lang => ({ params: { lang } }))) satisfies GetStaticPaths; 6const lang = getLocale(Astro.params.lang); 7const t = messages[lang]; 8--- 9<BaseLayout title={t.about} lang={lang}> 10 <h1 class="text-3xl font-bold">{t.about}</h1> 11 <p class="mt-4">{t.bio}</p> 12</BaseLayout>
7. API de contacto y formulario #
El endpoint no puede prerenderizarse: recibe solicitudes reales, comprueba origen, formato y datos, y devuelve códigos HTTP apropiados. La comprobación del navegador mejora la experiencia, pero la validación Zod del servidor es la que comprueba los datos recibidos. Antes de conectar un servicio de correo real añade las medidas contra abuso adecuadas a ese servicio.
📄 src/pages/api/message.ts
1import type { APIRoute } from 'astro'; 2import { z } from 'astro/zod'; 3 4export const prerender = false; 5const messageSchema = z.object({ 6 name: z.string().trim().min(2).max(80), 7 email: z.email(), 8 message: z.string().trim().min(10).max(2000), 9}); 10 11export const POST: APIRoute = async ({ request, url }) => { 12 if (request.headers.get('origin') !== url.origin) { 13 return Response.json({ ok: false }, { status: 403 }); 14 } 15 if (!request.headers.get('content-type')?.includes('application/json')) { 16 return Response.json({ ok: false }, { status: 415 }); 17 } 18 let payload: unknown; 19 try { 20 payload = await request.json(); 21 } catch { 22 return Response.json({ ok: false }, { status: 400 }); 23 } 24 const result = messageSchema.safeParse(payload); 25 if (!result.success) { 26 return Response.json({ ok: false }, { status: 400 }); 27 } 28 // Demostración: aquí se conectaría un servicio de correo o almacenamiento. 29 return Response.json({ ok: true }); 30};
📄 src/pages/[lang]/contact.astro
1--- 2import type { GetStaticPaths } from 'astro'; 3import BaseLayout from '../../layouts/BaseLayout.astro'; 4import { locales, getLocale, messages } from '../../i18n/ui'; 5export const getStaticPaths = (() => locales.map(lang => ({ params: { lang } }))) satisfies GetStaticPaths; 6const lang = getLocale(Astro.params.lang); 7const t = messages[lang]; 8--- 9<BaseLayout title={t.contact} lang={lang}> 10 <h1 class="text-3xl font-bold">{t.contact}</h1> 11 <form id="contact-form" class="mt-6 grid gap-4" data-lang={lang}> 12 <label>{t.name}<input name="name" required minlength="2" maxlength="80" autocomplete="name" class="block w-full rounded border p-2" /></label> 13 <label>{t.email}<input name="email" type="email" required autocomplete="email" class="block w-full rounded border p-2" /></label> 14 <label>{t.message}<textarea name="message" required minlength="10" maxlength="2000" class="block w-full rounded border p-2"></textarea></label> 15 <button class="rounded bg-marca p-3 text-white disabled:opacity-50" type="submit">{t.send}</button> 16 <p role="status" aria-live="polite"></p> 17 </form> 18 <script> 19 function setupContact() { 20 const form = document.querySelector<HTMLFormElement>('#contact-form'); 21 const status = form?.querySelector<HTMLElement>('[role="status"]'); 22 const button = form?.querySelector('button'); 23 if (!form || !status || !button || form.dataset.ready) return; 24 form.dataset.ready = 'true'; 25 const es = form.dataset.lang === 'es'; 26 form.addEventListener('submit', async event => { 27 event.preventDefault(); 28 button.disabled = true; 29 status.textContent = es ? 'Enviando…' : 'Sending…'; 30 try { 31 const response = await fetch('/api/message', { 32 method: 'POST', 33 headers: { 'Content-Type': 'application/json' }, 34 body: JSON.stringify(Object.fromEntries(new FormData(form))), 35 }); 36 if (!response.ok) throw new Error('HTTP error'); 37 status.textContent = es 38 ? 'Solicitud validada. Esta demostración no envía correos.' 39 : 'Request validated. This demo does not send email.'; 40 form.reset(); 41 } catch { 42 status.textContent = es ? 'No se pudo enviar. Inténtalo de nuevo.' : 'Could not send. Please try again.'; 43 } finally { 44 button.disabled = false; 45 } 46 }); 47 } 48 setupContact(); 49 document.addEventListener('astro:page-load', setupContact); 50 </script> 51</BaseLayout>
La inicialización se ejecuta al cargar y en astro:page-load; la marca data-ready evita listeners duplicados. El formulario solo anuncia éxito cuando la API responde correctamente y conserva los datos si falla el envío.
Laboratorio interactivo
Formulario: validación nativa, campo a campo
Experimenta con required, type, min, max y step. El navegador calcula la validez y muestra qué restricción falla; el envío se simula dentro de este laboratorio.
Completa el formulario y comprueba sus restricciones.
<input type="email" required />
<input type="number" min="1" max="5" step="1" required />
<input type="checkbox" required />
if (form.checkValidity()) { /* continuar */ }noValidate permite interceptar el submit y enseñar los errores en estas tarjetas. checkValidity sigue aplicando las restricciones HTML. Cada mensaje procede de validationMessage y puede cambiar según el idioma del navegador. Ningún dato se envía.
Experimenta: Carga el ejemplo inválido, corrige el correo y prueba 0, 6 y 2.5 plazas. Marca las condiciones y consigue cuatro campos válidos.
8. Comprobar el proyecto #
1npm run check 2npm run build 3npm run start
Para el adaptador Node, npm run start ejecuta el servidor compilado, por defecto en el puerto 4321. astro preview es una previsualización local, no el servidor de producción.
Comprueba:
/redirige a/es/;/es/y/en/muestran sus textos y proyectos.- Cada enlace abre su detalle; el artículo MDX muestra un contador operativo.
- La valoración responde al ratón y al teclado. No se comparten puntuaciones entre páginas.
- Contacto funciona tras entrar directamente y tras navegar con el menú.
- Un formulario válido muestra que se ha validado la solicitud; una respuesta de error no se presenta como éxito.
- El diseño se adapta al móvil y muestra foco visible al usar Tab.
9. Publicar en Vercel #
El destino de despliegue determina el adaptador. Para Vercel sustituye solo el import y la opción adapter de astro.config.mjs:
1import vercel from '@astrojs/vercel'; 2// Conserva mdx(), react(), vite e i18n. 3// En defineConfig: 4adapter: vercel(),
Retira el import de @astrojs/node y añade tu dominio real a security.allowedDomains, por ejemplo { hostname: 'mi-portfolio.vercel.app', protocol: 'https' }, sustituyendo el nombre del ejemplo. Si después añades un dominio personalizado, decláralo también. Ejecuta de nuevo npm run check y npm run build, sube el repositorio y conecta el proyecto en Vercel con Node 24. Su integración utiliza el resultado de su adaptador; no ejecutes npm run start en Vercel. Repite las comprobaciones sobre la URL publicada.
Para Netlify usa su adaptador. GitHub Pages solo sirve archivos estáticos: para publicar allí esta web debes retirar o sustituir el formulario y su endpoint; no puede ejecutar este backend. No incluyas claves privadas ni archivos .env en Git.