A fast, accessible, content-driven portfolio built with Astro 5 and Tailwind CSS v4. Static-first, zero client-side JavaScript by default, dark mode that respects system preference, and a content collection for projects you can edit in plain Markdown.
Placeholder identity — see the TODO section to swap in your real name, photos, and projects.
| Performance | Accessibility | Best Practices | SEO |
|---|---|---|---|
| 92 | 96 | 100 | 100 |
Measured on the production build at dist/ with lighthouse --quiet --only-categories=performance,accessibility,best-practices,seo. See INTEGRATION_REPORT.md for the full audit.
| Desktop (1280×800) | Tablet (768×1024) | Mobile (375×812) |
|---|---|---|
![]() |
![]() |
![]() |
More: mobile menu · project filtering · form validation
- Six pages — Home, About, Projects, Services, Contact, plus a custom 404.
- Content collections — projects are typed Markdown files in
src/content/projects/; no CMS, no DB. - Dark mode with FOUC-free inline bootstrap, system-preference aware, and a manual toggle that persists in
localStorage. - A11y out of the box — skip link, semantic landmarks, visible focus rings, WCAG AA contrast,
prefers-reduced-motionguards, formaria-liveregions. - SEO — per-page
<title>and<meta>, Open Graph, Twitter Card, JSON-LDPersonschema, canonical URLs,sitemap.xml,robots.txt,/.well-known/security.txt. - Performance — fully static output, zero runtime JS by default, AVIF/WebP responsive images via
astro:assets, inlined critical CSS, fontdisplay=swap, total bundle 3.4 MB (CSS 34 KB, JS 0 KB shipped). - Contact form with client-side validation, loading state, success screen, and a swap-in path to a real backend (Formspree, Netlify Forms, or your own).
- Project filtering on
/projects— animated category chips, no client framework. - Animations — scroll-reveal via
IntersectionObserver, gated byprefers-reduced-motion.
| Layer | Choice | Notes |
|---|---|---|
| Framework | Astro 5.x | Static-site generation, islands architecture, zero JS by default. |
| Styling | Tailwind CSS 4.x | Loaded via @tailwindcss/vite; design tokens defined in @theme. |
| Language | TypeScript | extends: astro/tsconfigs/strict. |
| Sitemap | @astrojs/sitemap | Emits sitemap-index.xml and sitemap-0.xml at build time. |
| Content | Markdown + frontmatter | No MDX — projects are typed via Astro content collections. |
| Client JS | None (by design) | Theme toggle, mobile menu, contact form, scroll-reveal are vanilla TS. |
| Hosting | Any static host | Outputs to dist/ — Netlify, Vercel, Cloudflare Pages, GitHub Pages. |
portfolio/
├── astro.config.mjs # Astro config (site URL, integrations, vite plugins)
├── package.json # Scripts and dependencies
├── tsconfig.json # Extends astro/tsconfigs/strict
├── public/ # Static assets served as-is
│ ├── favicon.svg # Brand mark
│ ├── og-image.png # 1200×630 Open Graph image
│ ├── robots.txt # Crawler directives
│ ├── .well-known/
│ │ └── security.txt # RFC 9116
│ └── images/icons/ # 16 hand-rolled SVG icons
├── src/
│ ├── _raw-assets/ # Source raster images (NOT copied to dist/)
│ ├── components/ # 14 reusable .astro components
│ │ ├── BaseLayout.astro # → src/layouts/BaseLayout.astro
│ │ ├── Navbar.astro # Top nav + mobile menu trigger
│ │ ├── Footer.astro
│ │ ├── Hero.astro
│ │ ├── ProjectCard.astro # Used in /projects and home featured
│ │ ├── ServiceCard.astro
│ │ ├── SkillBar.astro
│ │ ├── TestimonialCard.astro
│ │ ├── SectionHeading.astro # Accepts level="h1" | "h2" | "h3"
│ │ ├── ContactForm.astro # Self-contained form with inline script
│ │ ├── ThemeToggle.astro
│ │ ├── MobileMenu.astro
│ │ ├── ScrollReveal.astro
│ │ ├── SeoHead.astro # Centralized title/canonical/OG/Twitter/JSON-LD
│ │ └── SkipLink.astro
│ ├── content/
│ │ └── projects/ # 6 Markdown project entries
│ │ ├── lattice-orm.md
│ │ ├── multi-tenant-billing-platform.md
│ │ ├── offline-first-notes.md
│ │ ├── polaris-component-library.md
│ │ ├── realtime-analytics-dashboard.md
│ │ └── shipline-cli.md
│ ├── data/
│ │ └── content.ts # Typed source of truth for all site copy + SEO
│ ├── layouts/
│ │ └── BaseLayout.astro # Shared <html>, <head>, Navbar, Footer
│ ├── lib/
│ │ └── seo.ts # Typed title/canonical/image URL builders
│ ├── pages/ # File-based routes
│ │ ├── index.astro # Home
│ │ ├── about.astro
│ │ ├── projects.astro
│ │ ├── services.astro
│ │ ├── contact.astro
│ │ └── 404.astro
│ ├── scripts/
│ │ └── theme.ts # Client-side theme helper (mirrors inline bootstrap)
│ └── styles/
│ ├── global.css # Tailwind import + @theme tokens + components
│ └── a11y.css # skip-link, sr-only, focus rings, reduced-motion guard
├── docs/
│ ├── design-and-content.md # Design system rationale and final copy
│ ├── screenshots/ # Cross-device renders used in this README
│ ├── BUILD_REPORT.md # What shipped + known follow-ups
│ └── INTEGRATION_REPORT.md # Final Lighthouse + audit details
├── .gitignore
├── LICENSE # MIT
└── README.md # You are here
- Node.js 18.17+ (developed against Node 20+; project.json engines is loose).
- npm (lockfile is
package-lock.json).
npm installnpm run devOpen http://localhost:4321. Hot-reloads on edits to src/.
npm run buildOutputs static HTML/CSS/JS to dist/. Images are run through astro:assets and emitted as AVIF/WebP variants.
npm run previewServes the dist/ output locally on http://localhost:4321.
A multi-stage Dockerfile is included (Node 20-alpine for the build, nginx 1.27-alpine for serving). The final image is ~82 MB.
# Build — pass your real site URL so canonical + OG + sitemap use it
docker build -t portfolio --build-arg SITE_URL=https://your-domain .
# Run — port 8080 in the container; map to whatever you want
docker run -d --name portfolio -p 8080:8080 portfolio
# Open http://localhost:8080The container binds to 8080 (not 80) so it runs as non-root out of the box — works on Railway, Fly, Cloud Run, Render, and any other platform that expects non-privileged containers. The bundled nginx.conf adds security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy), serves a 1-year immutable cache on hashed /_astro/* assets, and handles Astro's trailingSlash: 'never' URLs without redirect loops.
.dockerignore keeps the build context small (no node_modules/, no dist/, no VCS, no dev tooling).
The two files you'll touch most: src/data/content.ts (copy + identity + SEO) and src/styles/global.css (design tokens).
All identity fields live in src/data/content.ts under the site export. Edit these and the rest of the site updates automatically.
// src/data/content.ts
export const site = {
name: 'Alex Morgan', // → your full name
shortName: 'AM', // → initials or short form
tagline: 'Full-Stack Engineer • …',
description: 'Senior full-stack engineer …',
url: 'https://alexmorgan.dev', // → your real URL
email: 'hello@alexmorgan.dev', // → your real email
social: {
github: 'https://github.com/alex-morgan',
linkedin: 'https://www.linkedin.com/in/alex-morgan',
twitter: 'https://twitter.com/alex_morgan',
},
} as const;Long-form copy (about bio, services, testimonials, project descriptions) is in the same file — search for about, services, testimonials, etc.
Projects are plain Markdown files in src/content/projects/. Create a new file (or edit an existing one) with frontmatter:
---
title: "Lattice ORM"
description: "TypeScript ORM focused on type-safe queries without runtime weight."
year: 2023
category: "Open Source"
stack: ["TypeScript", "PostgreSQL", "MySQL", "SQLite", "Vitest"]
---
A TypeScript ORM that focuses on type-safe queries without the runtime
weight of a query builder. The whole package is 11 KB minified, gzipped.
**Outcome:** Adopted by 3 YC companies and several indie SaaS tools.The list of valid category values matches the filter chips on /projects. The first file in the collection becomes the featured card on the home page.
Two files, kept in sync:
src/styles/global.css — design tokens inside @theme { … }:
@theme {
--color-bg: #F8FAFC; /* light bg */
--color-surface: #FFFFFF; /* cards */
--color-text: #0F172A;
--color-text-muted: #475569;
--color-accent: #D97706; /* UI accent (borders, focus, icons) */
--color-accent-text: #B45309; /* accent body text (passes AA on bg) */
--color-border: #E2E8F0;
--color-border-strong:#CBD5E1;
}The dark-mode overrides sit inside .dark { … } further down in the same file.
Tip: keep
--color-accentfor decoration (borders, icons, focus rings) and--color-accent-textfor any accent-colored body text. That's what keeps WCAG AA contrast.
Per-page SEO lives in the seo export at the bottom of src/data/content.ts:
export const seo = {
'/': { title: 'Alex Morgan — Full-Stack Engineer', description: '…' },
'/about': { title: 'About — Alex Morgan', description: '…' },
'/projects': { title: 'Projects — Alex Morgan', description: '…' },
'/services': { title: 'Services — Alex Morgan', description: '…' },
'/contact': { title: 'Contact — Alex Morgan', description: '…' },
} as const;Social links (used in JSON-LD, Open Graph site_name, and the footer) are in the site.social object shown above.
The current ContactForm.astro simulates a 1.5 s network round-trip and shows a success state. Replace the await new Promise(...) line in the inline <script> with a real fetch. Three options, smallest diff first.
// inside the form's <script> submit handler
const res = await fetch('https://formspree.io/f/YOUR_FORM_ID', {
method: 'POST',
headers: { 'Accept': 'application/json' },
body: new FormData(form),
});
if (!res.ok) throw new Error('Submission failed');Set the form's action and method for progressive enhancement (works without JS):
<form action="https://formspree.io/f/YOUR_FORM_ID" method="POST" id="contact-form" ...><form
name="contact"
method="POST"
data-netlify="true"
netlify-honeypot="bot-field"
id="contact-form"
>
<input type="hidden" name="form-name" value="contact" />
<p class="hidden"><label>Don't fill: <input name="bot-field" /></label></p>
<!-- … fields … -->
</form>No JS change needed — Netlify intercepts the POST. The honeypot field is a spam trap.
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: data.get('name'),
email: data.get('email'),
subject: data.get('subject'),
message: data.get('message'),
}),
});Then in src/pages/api/contact.ts (Astro endpoint, requires SSR adapter) or a serverless function on your host.
astro.config.mjs — the site field is used for canonical URLs, sitemap entries, and Open Graph absolute paths:
export default defineConfig({
site: 'https://alexmorgan.dev', // ← change this
trailingSlash: 'never',
// …
});Also update site.url in src/data/content.ts — the JSON-LD Person schema reads from there.
The build outputs a fully static site to dist/. Pick any host.
Drag-and-drop:
npm run build- Open https://app.netlify.com/drop
- Drag the
dist/folder onto the page. Done.
CLI:
npm install -g netlify-cli
npm run build
netlify deploy --prod --dir=distNetlify auto-detects Astro and runs npm run build with dist as the publish directory — no netlify.toml needed.
Dashboard:
- Push the repo to GitHub/GitLab/Bitbucket.
- Import it at https://vercel.com/new.
- Vercel auto-detects Astro. Click Deploy.
CLI:
npm install -g vercel
vercel --prod- Push the repo to GitHub.
- In the Cloudflare dashboard → Workers & Pages → Create → Pages → Connect to Git.
- Pick the repo.
- Set:
- Build command:
npm run build - Build output directory:
dist - Node version: 20 (set under Environment variables →
NODE_VERSION=20)
- Build command:
- Click Save and Deploy.
Subsequent pushes to the main branch redeploy automatically.
gh-pages npm package:
npm install --save-dev gh-pages
npm run build
npx gh-pages -d distThen in your repo's Settings → Pages, set the source to the gh-pages branch, / (root).
GitHub Actions (CI on every push to main):
Create .github/workflows/deploy.yml:
name: Deploy to GitHub Pages
on:
push:
branches: [main]
permissions:
contents: read
pages: write
id-token: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: withastro/action@v3
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: ./dist
deploy:
needs: build
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- uses: actions/deploy-pages@v4Custom domain? Add a
CNAMEfile topublic/with your domain. After the first deploy, configure DNS and HTTPS in repo Settings → Pages.
The build is tuned for a fast, lean default experience. Targets and how they're achieved:
| Metric | Target | How it's achieved |
|---|---|---|
| PageSpeed Insights | 90+ | Static HTML, inlined critical CSS, AVIF/WebP images, no render-blocking JS. Actual: 92. |
| LCP (4G mobile) | < 2.0 s | Preconnect to Google Fonts, font-display: swap, hero image is astro:assets AVIF with width hints. |
| Total client JS | 0 KB shipped | No client framework. Theme/menu/form/scroll-reveal are vanilla TS. |
| Cumulative Layout Shift | < 0.05 | All images have width/height, web font fallback metrics, no late-injected content. |
| Time to First Byte | < 600 ms | Static HTML — any CDN edge will hit this on a warm cache. |
Total dist/ size |
< 5 MB | Actual: 3.4 MB (1.9 MB of that is the OG image; everything else is sub-MB). |
# Lighthouse CLI against your running preview server
npx lighthouse http://localhost:4321 --view
# Or against a deployed URL
npx lighthouse https://your-domain --viewOr paste the URL into https://pagespeed.web.dev/ for the PageSpeed Insights API report (lab + field data).
The build covers WCAG 2.1 AA out of the box:
- Color contrast — every text/background pair clears AA (verified in
docs/design-and-content.md). Accent body text uses--color-accent-text(not the lighter--color-accent). - Keyboard navigation — every interactive element is reachable via Tab, with a visible 2 px focus ring.
- Skip link —
<a href="#main" class="skip-link">is the first focusable element on every page. - Semantic landmarks —
<header>,<nav>,<main id="main">,<footer>are used throughout. - Form labels +
aria-live— every input has a<label>, error messages usearia-live="polite", and the form's status container isrole="status". - Reduced motion —
@media (prefers-reduced-motion: reduce)zeros out animations and disables smooth scroll. - Theme toggle — uses
aria-pressedand is keyboard-operable. - One
<h1>per page — every page has exactly one top-level heading;SectionHeading.astroacceptslevel="h1" | "h2" | "h3"to keep the hierarchy explicit.
- Code: MIT. Fork it, ship it, attribution appreciated but not required.
- Framework: Astro — MIT.
- Styling: Tailwind CSS — MIT.
- Fonts (via Google Fonts):
- Inter — body text, OFL.
- Space Grotesk — display headings, OFL.
- JetBrains Mono — monospace, OFL.
- Icons: Hand-rolled inline SVG components in
src/components/andpublic/images/icons/. - Project images: Generated for the demo, not stock — replace with your own.
This portfolio was scaffolded with placeholder identity and content. Before going live, replace the following:
- Replace "Alex Morgan" everywhere. Search the repo for
alexmorgan,Alex Morgan, andAM(initials). The two main sources aresrc/data/content.ts(site,seo) anddocs/design-and-content.md. - Replace the profile and about photos. Drop your real photos into
src/_raw-assets/(e.g.profile.jpg,about-photo.jpg) — the build runs them throughastro:assetsautomatically. - Replace the 6 project mockups in
src/_raw-assets/with real screenshots, and update theimagefield in eachsrc/content/projects/*.mdfrontmatter. - Wire the contact form to a real backend. See the Customization guide §5 — Formspree, Netlify Forms, or a custom endpoint.
- Update social links in the
site.socialobject insrc/data/content.ts(used by the footer, JSON-LD, and the/contactpage). - Set the real site URL in
astro.config.mjs(site: 'https://your-domain') and insrc/data/content.ts(site.url). The sitemap and canonical URLs are generated from these. - (Optional) Swap the Open Graph image at
public/og-image.pngwith one that has your name/photo and the new brand color. - (Optional) Remove the "Lighthouse scores" section once your real build's scores are confirmed, or update them with your real numbers.
- (Optional) Set up auto-deploy: this repo includes a
.github/workflows/deploy.ymlthat builds and deploys to GitHub Pages on every push tomain. To enable: go to repo Settings → Pages, set Source to GitHub Actions. The build readsSITE_URLfrom the repo's Variables (Settings → Secrets and variables → Variables) so canonical/OG/sitemap use your real domain; if not set, it defaults tohttps://<owner>.github.io/<repo>.


