Personal portfolio built with Nuxt 4, Nuxt UI 4 and Tailwind CSS 4, themed after
laravel.com: warm off-black/off-white neutrals, Laravel red (#F53003)
as the single accent, Instrument Sans + JetBrains Mono, dot-grid backdrops and code windows.
Everything is statically prerendered. There is no CMS and no database — all content lives in typed data files.
pnpm install
pnpm dev # http://localhost:3000
pnpm build # prerenders every route into .output/public
pnpm preview
pnpm lint
pnpm typecheckNothing is hardcoded in components. Edit the files in app/data/ and the pages update:
| File | Contains |
|---|---|
app/data/site.ts |
Domain, SEO defaults, nav, footer, socials, résumé path, 404 copy, feed paths |
app/data/identity.ts |
Name, title, tagline, status, location + timezone, photo, hero bio, long bio, specialisation, languages, interests |
app/data/projects.ts |
Case studies: problem, body, role, team size, timeline, status, stack, architecture decisions, challenges, outcomes, URLs, screenshots, demo, category, featured flag |
app/data/roles.ts |
Roles: company, URL, title, employment type, dates, location, achievements, stack, highlights |
app/data/skills.ts |
Skills with category, proficiency tier and years used |
app/data/repos.ts |
Packages and upstream contributions with stars/forks/downloads |
app/data/posts.ts |
Blog posts: excerpt, body, dates, tags, reading time, cover, canonical URL, external publication |
app/data/credentials.ts |
Education, certifications, talks, testimonials |
app/types/content.ts defines every shape. Adding a field there makes pnpm typecheck tell you
exactly which data files still need it.
Case studies and posts use typed blocks instead of markdown, so there is no content dependency and the bodies are type-checked:
body: [
{ type: 'heading', text: 'Why build another monitor' },
{ type: 'paragraph', text: '…' },
{ type: 'code', language: 'php', filename: 'app/Jobs/Probe.php', code: '…' },
{ type: 'list', ordered: true, items: ['…'] },
{ type: 'quote', text: '…', cite: 'Client finance lead' },
{ type: 'callout', tone: 'warning', title: 'Do not average this', text: '…' },
{ type: 'image', image: { src: '/images/…', alt: '…' }, caption: '…' }
]The site ships with realistic seed content so every field is visible. Before publishing:
- All of
app/data/*— names, dates, metrics, URLs and quotes are invented. site.domaininapp/data/site.ts— drives canonical URLs, sitemap, RSS and OG image URLs.site.analyticsId— absent by default. Add a GA4 measurement id to switch the snippet on.public/images/**— generated SVG placeholders. Real screenshots can be.png/.jpg; update thesrcin the data file to match.public/resume/muntaser-muttaqi-cv.pdf— printed from the seed data, so it is only as real asapp/data/*. It regenerates itself on the next build once the data is yours (see below).public/og-image.svg— replace with a 1200×630 PNG for the widest social-platform support, then updatesite.ogImage.public/apple-touch-icon.pngandpublic/favicon.ico— solid-colour placeholders.- Project demo — no project sets
demoyet. Drop an.mp4inpublic/media/and adddemo: { type: 'video', src, alt }to a project to render one.
/resume renders a print-styled A4 sheet from app/data/*, so the PDF can never drift from the
site. It is noindex and deliberately absent from the nav and sitemap.
You never export it by hand. modules/resume-pdf.ts prints the prerendered page to
public/resume/muntaser-muttaqi-cv.pdf at the end of every nuxt build, so editing app/data/*
and rebuilding is the whole workflow. The repo copy is rewritten too, so the diff shows up in
git status.
It uses whichever Chrome-family browser it finds on the machine; set CHROME_PATH to point at a
specific one. If none is found the build logs a warning, continues, and ships the committed PDF —
stale, but never broken. /resume stays browsable and still has a Print / Save as PDF button
if you want a one-off export.
One thing is still manual: site.resume.updatedAt is hand-typed, so it can claim a date the PDF
does not deserve. Bump it when the data changes.
There is no server route. The site deploys as static files, so ContactForm.vue posts straight to
Web3Forms from the browser, using the public access key in runtimeConfig.public.web3formsAccessKey
— that key only authorises posting to the inbox it was issued for. A hidden honeypot field filters
the obvious bots.
Override the key per environment with NUXT_PUBLIC_WEB3FORMS_ACCESS_KEY. Setting it to an empty
string wipes the key and breaks the form.
- Per-page
useSeoMetawith Open Graph and Twitter cards - JSON-LD
Personon every page,CreativeWorkon case studies,BlogPostingon posts - Canonical URLs, with cross-posted articles pointing at their original publication
/sitemap.xml,/rss.xml(full content) and/robots.txtgenerated from the same datasite.webmanifestplus SVG and PNG app icons
The Laravel palette lives in app/assets/css/main.css as two Tailwind scales — laravel-* (red)
and artisan-* (warm neutrals) — wired into Nuxt UI via app/app.config.ts. Custom utilities:
bg-dot-grid, bg-ember, bg-rule-fade, text-balance-tight, panel, tag-pill. Light mode is
the default; the header toggle switches it.