Skip to content
RY

Building a Stunning Portfolio and Blog with Astro + MDX, Deployed to Vercel from a Private GitHub Repo

Ram Laxman Yadav8 min read

A portfolio site has a narrow job: load fast, read well, and not fight you every time you want to write a new post. Astro’s actual output for a content-heavy site like this is static HTML with close to zero shipped JavaScript — you only pay for the JS that an interactive component genuinely needs, and a blog post is rarely one of those. Paired with MDX for the posts themselves and Tailwind v4 for styling, that’s the exact stack this site runs on. Here’s the setup end to end, including the one part people usually get stuck on — pointing Vercel at a private GitHub repo instead of a public one.

Scaffolding the project

Terminal window
npm create astro@latest

Pick the minimal template if you’re going to hand-build the layout (which you want for a portfolio — the generated blog template fights you more than it helps). Then add MDX support:

Terminal window
npx astro add mdx

That command does three things for you: installs @astrojs/mdx, registers it in astro.config.mjs, and lets you write .mdx files anywhere Astro looks for pages or content — meaning a blog post can import and render a real Astro or React component inline, not just Markdown.

Styling with Tailwind v4

Tailwind v4 dropped the old Astro integration in favor of a native Vite plugin, which is both simpler and the version you actually want today:

Terminal window
npm install tailwindcss @tailwindcss/vite
astro.config.mjs
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
vite: {
plugins: [tailwindcss()],
},
});
src/styles/global.css
@import 'tailwindcss';

No tailwind.config.js to maintain for the common case — v4 configures itself from the CSS file via @theme blocks if you need custom tokens, which keeps one less file to keep in sync with your design.

Content collections: typed, validated blog posts

This is the part that actually makes a blog maintainable past the first ten posts. Astro’s content layer lets you define a schema once and get full TypeScript types and runtime validation on every post’s frontmatter:

src/content.config.ts
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'zod';
const blog = defineCollection({
loader: glob({ pattern: '**/[^_]*.{md,mdx}', base: './src/content/blog' }),
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
author: z.string().default('Your Name'),
tags: z.array(z.string()).default([]),
category: z.string(),
heroImage: image().optional(),
draft: z.boolean().default(false),
featured: z.boolean().default(false),
}),
});
export const collections = { blog };

A post is then just a file with frontmatter Zod actually checks at build time — miss a required field and the build fails loudly instead of shipping a broken page:

---
title: 'Why I Switched My Blog to Astro'
description: 'What changed, what broke, and what got faster.'
pubDate: 2026-10-08
author: 'Your Name'
tags: ['Astro', 'Meta']
category: 'Frontend'
---
import Callout from '@components/mdx/Callout.astro';
Regular Markdown works exactly as you'd expect. The difference shows up the moment you need something Markdown can't express —
<Callout type="info">
like this box, which is a real Astro component imported directly into the post and rendered
server-side, with zero client-side JavaScript shipped for it.
</Callout>

Rendering the list and the post page

Two files turn that collection into an actual blog: an index that lists posts, and a dynamic route that renders one.

src/pages/blog/[slug].astro
---
import { getCollection, render } from 'astro:content';
import BaseLayout from '@layouts/BaseLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.draft);
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<BaseLayout title={post.data.title} description={post.data.description}>
<article class="prose dark:prose-invert mx-auto">
<Content />
</article>
</BaseLayout>

getStaticPaths runs at build time, so every post becomes a prerendered HTML file — there’s no server doing work per request, which is most of why a static Astro blog is fast by default rather than fast after tuning.

One shared layout, one theme toggle

A single BaseLayout.astro wrapping every page keeps SEO tags, fonts, and the dark/light toggle in one place instead of duplicated per page:

src/layouts/BaseLayout.astro
---
import '../styles/global.css';
const { title, description } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>{title}</title>
<meta name="description" content={description} />
<script is:inline>
(function () {
var stored = null;
try {
stored = localStorage.getItem('theme');
} catch (e) {}
var theme =
stored === 'light' || stored === 'dark'
? stored
: window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
</head>
<body>
<slot />
</body>
</html>

That inline script runs before anything paints, which is why a dark-mode site doesn’t flash white for a frame on load — it has to run synchronously in <head>, before the stylesheet that reads data-theme even applies.

Warning

Don’t defer or externalize that theme script. The entire point is that it blocks rendering just long enough to set the attribute Tailwind’s dark-mode selector reads — moving it to <body> or loading it async reintroduces the flash it exists to prevent.

The finishing touches that make it feel production-grade

A few integrations round this out without much code:

Terminal window
npx astro add sitemap # XML sitemap, generated at build time
npm i astro-expressive-code # syntax-highlighted code blocks with copy buttons
npm i @vercel/speed-insights @vercel/analytics # once deployed to Vercel

astro-expressive-code is worth calling out specifically for a technical blog — it gives you per-language syntax highlighting with light/dark theme switching baked in, which plain Markdown code fences don’t.

Deploying to Vercel from a private GitHub repository

This is the part that trips people up, because Vercel’s “Import Git Repository” screen only shows repos it already has access to — and by default, a fresh GitHub connection often doesn’t include your private ones.

1. Push your project to a private GitHub repo.

Terminal window
git init
git add .
git commit -m "Initial commit"
git remote add origin git@github.com:your-username/your-repo.git
git push -u origin main

Create the repo on GitHub first with visibility set to Private before pushing.

2. Start the import on Vercel.

Go to the Vercel dashboard → Add New… → Project. If your private repo doesn’t show up in the list, don’t assume something’s broken — this is the default state, not an error.

3. Grant the Vercel GitHub App access to the private repo.

Click Configure GitHub App (or Adjust GitHub App Permissions, depending on where you land) from the import screen. That takes you to GitHub’s own app-permissions settings for Vercel, where you choose either:

  • All repositories — Vercel can see every repo you own or have access to, public and private, or
  • Only select repositories — pick your portfolio repo specifically from the list

The “only select repositories” option is the one worth taking for a personal account — it means Vercel never sees anything beyond the repos you explicitly hand it, which matters more the more private repos you have.

You can reach the same screen directly from GitHub: Settings → Applications → Installed GitHub Apps → Vercel → Configure.

4. Re-run the import.

Back on Vercel’s New Project page, your private repo now appears in the list. Select it — Vercel auto-detects the Astro framework preset, sets the build command to astro build and the output directory to dist without you touching either field.

5. Deploy.

Click Deploy. First build typically finishes in under a minute for a static Astro site. You get a your-project.vercel.app URL immediately, with HTTPS already configured.

6. Connect a custom domain (optional).

Project Settings → Domains → add your domain → follow the DNS instructions Vercel shows (an A record to Vercel’s IP for an apex domain, or a CNAME for a subdomain like www). Vercel issues and renews the TLS certificate automatically once DNS propagates.

7. Confirm the git integration is live.

Push a commit to main and watch Vercel’s dashboard — a production deployment kicks off automatically. Open a pull request against the repo and Vercel comments on it with a unique preview URL for that branch, built from the exact commit, before you ever merge. This works identically for private repos once the GitHub App has access — the deploy hook isn’t a public webhook, it’s authenticated through the same App installation from step 3.

Note

If an org-owned private repo still doesn’t show up after granting access, check whether an org owner needs to separately approve the Vercel GitHub App installation for that organization — personal-account repos skip this approval step, org repos sometimes don’t.

That’s the full loop: write a post as an .mdx file with validated frontmatter, push to main, and Vercel has it live — typed content, fast builds, and dark mode that doesn’t flash, running off a repository nobody but you can see the source of.

If you’d rather start from a design someone else already polished, Astro’s own themes showcase is worth browsing — plenty of free, MDX-ready blog and portfolio themes in there to fork instead of building the layout from scratch.

Happy writing!