Every project is an on going one...
Git, APIs, CI/CD, localization, and deployment infrastructure can fit together.
GitHub became more than authentication
Once GitHub was involved, several capabilities became available through its API.
The application could:
- create content files
- update existing files
- delete files
- inspect files
- retrieve commit history
- identify changes associated with publishing operations
That meant the Admin Dashboard didn't need to behave like a traditional CMS backed by a database.
It could behave more like a controlled interface for manipulating the repository itself.
The resulting flow became:
Admin Dashboard
│
▼
Content Editor
│
▼
Content API
│
▼
Content Layer
│
▼
GitHub Contents API
│
▼
RepositoryAnd then Vercel takes over:
GitHub
│
│ repository change
▼
Vercel
│
▼
New deployment
│
▼
New production buildWhich gives us the rather satisfying:
Admin Dashboard → GitHub → Vercel → New Production Build
The content structure had to evolve too
The original content model was already designed around localization.
A document wasn't simply:
post.mdxIt was a logical document containing multiple translations.
For example:
content/
blog/
my-post/
en.mdx
pt.mdxThe application represents that concept as a content document with translations.
That distinction became important once editing was introduced.
An editor isn't really editing an en.mdx file.
It's editing a content document that may contain an English translation, a Portuguese translation, or both.
The publishing layer then has to translate that domain model back into repository files.
For example:
ContentDocument
│
├── en
│ └── content/blog/my-post/en.mdx
│
└── pt
└── content/blog/my-post/pt.mdxEach translation becomes its own repository operation.
That sounds straightforward.
It wasn't quite as straightforward as I expected.
Separating content from GitHub
One of the more important refactors was realizing that lib/content shouldn't actually know how GitHub works.
Initially, these concerns were becoming intertwined.
The content layer knew about content directories, filesystem paths, GitHub URLs, repository configuration, and API operations.
That worked.
Until it didn't.
The problem was conceptual as much as technical.
There are two different kinds of paths in this system.
A local filesystem path might look like:
D:/alexandre-blog/content/blog/my-post/en.mdxA GitHub repository path should look like:
content/blog/my-post/en.mdxThose are not interchangeable.
And they definitely shouldn't be passed interchangeably to an API.
So the architecture was split into two responsibilities.
lib/content
Responsible for the content domain:
- reading MDX
- parsing frontmatter
- constructing content documents
- handling translations
- serializing content
- determining local content paths
lib/github/content
Responsible for the GitHub infrastructure:
- repository configuration
- authentication headers
- GitHub API URLs
- repository-relative paths
- file lookup
- create
- update
- delete
- upsert
That gave us a much cleaner boundary:
Content domain
│
│ repository operation
▼
GitHub integration
│
▼
GitHub APIThe content system knows that it needs to persist a document.
It doesn't need to know that persistence happens through the GitHub Contents API.
The Vercel filesystem problem
This separation also became important because of where the application runs.
Locally, it's tempting to think of the repository filesystem as the source of truth.
You can read:
content/blog/my-post/en.mdxYou can modify it.
You can run Git.
Everything feels normal.
A deployed serverless environment is different.
The filesystem available to the application isn't a suitable persistent content store.
That meant the publishing operation couldn't simply do:
writeFile(...)and assume the article would become part of the repository.
Even worse, trying to treat the deployment environment like a normal Git working tree created an entirely different class of problems.
So the mutation model became explicit:
Read content locally when appropriate, but persist administrative mutations through GitHub.
The repository became the durable source of truth.
The GitHub Contents API
The actual publishing operations are deliberately small.
For a new translation, the application generates the repository path:
content/blog/my-post/en.mdxserializes the MDX and frontmatter, and sends it to GitHub.
For an update, GitHub requires the SHA of the existing file.
So the update flow becomes roughly:
Editor
│
▼
Find existing file
│
▼
Get SHA
│
▼
Serialize updated content
│
▼
Update file
│
▼
Create Git commitFor a new file:
Editor
│
▼
Check whether file exists
│
▼
Serialize content
│
▼
Create file
│
▼
Create Git commitAnd because each locale is a separate file, updating English doesn't require modifying Portuguese, and vice versa.
That ended up being a useful property of the architecture.
Create, Save, Update and Delete became Git operations
Once the editor was connected to GitHub, something interesting happened.
Actions that previously felt like application-level operations became repository-level operations.
Creating a post creates a commit.
Saving a translation creates a commit.
Updating a document creates a commit.
Deleting a document creates a commit.
Suddenly, the repository wasn't just where the source code lived.
It was also the history of the publishing system.
And that led to another idea:
If GitHub already knows what happened, why shouldn't the Admin Dashboard show it?
The Admin Dashboard became a publishing monitor
The original Admin Dashboard relied on local Git status.
That made sense when the application was operating against a local repository.
But it becomes much less useful when content is being created directly through GitHub from a deployed Preview or Production environment.
There isn't necessarily a useful local Git working tree there.
So the dashboard was changed to consume GitHub commit information instead.
The result is much closer to what the system actually needs:
Content Editor
│
▼
GitHub commit
│
▼
GitHub history
│
▼
Admin DashboardThe dashboard can then show the changes introduced by the publishing system.
Instead of asking:
"Are there local files modified?"
it can ask:
"What did the publishing system actually commit?"
That is a much more useful question in a deployed environment.
Preview and Production
Once the publishing mechanism worked, another architectural concern appeared.
I didn't want to point Preview at master while testing the publishing system.
That would mean every test article, translation, or save operation would be modifying the production content source.
So the repository configuration was made branch-aware.
The Preview deployment can point to a dedicated branch:
feat/publishing-system-preview-and-productionwhile Production points to:
masterThe application code doesn't need to know which branch it is operating against.
The environment configuration decides.
Conceptually:
Preview
│
└── GITHUB_REPOSITORY_BRANCH
│
▼
Preview publishing branch
Production
│
└── GITHUB_REPOSITORY_BRANCH
│
▼
masterThat separation makes testing much safer.
I can create a post from the deployed Preview application and watch the entire publishing cycle happen without touching production.
Once everything is validated, the branch can be merged.
The funny part: Git fought back
Naturally, the project couldn't let all of this happen without some interesting Git problems.
At one point, the publishing implementation had accidentally generated paths that looked like:
D:/alexandre-blog/content/blog/...instead of:
content/blog/...Git quite reasonably had no idea what I intended.
The repository ended up with malformed paths in the remote branch history.
That led to a particularly unpleasant situation where Git reported paths such as:
D:/alexandre-blog/content/blog/blog-github-post/pt.mdxand Windows subsequently refused to work with them as normal repository paths.
The lesson was fairly simple:
A filesystem path and a repository path are different abstractions.
Once that distinction was made explicit in the architecture, the problem went away.
The GitHub integration now receives repository-relative paths, while local filesystem helpers remain responsible for local paths.
That sounds obvious in hindsight.
It wasn't quite as obvious while debugging it.
Another lesson: --force-with-lease is your friend
Because the malformed paths and test commits had already reached the remote branch, cleaning everything up required rewriting the branch history.
But blindly doing:
git push --forcewould have been unnecessarily reckless.
Instead, I used a backup branch and --force-with-lease.
That allowed the local branch to become the canonical clean history while still protecting against accidentally overwriting a remote branch that had changed unexpectedly.
And, as it turned out, the remote branch did change while I was working.
The lease check caught that.
Git basically said:
"I know what you think the remote looks like, but it changed. I'm not going to overwrite it."
Which was exactly what I wanted it to say.
The architecture today
What started as a collection of MDX files now has a small publishing architecture around it.
At a high level:
┌──────────────────┐
│ Admin Dashboard │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Content Editor │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Admin API Routes │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ lib/content │
│ │
│ Content domain │
│ MDX / locales │
│ serialization │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ lib/github/ │
│ content │
│ │
│ GitHub adapter │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ GitHub Repository │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Vercel │
│ automatic build │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Published Site │
└──────────────────┘It's still a personal website.
It just happens to have a miniature publishing pipeline behind it now.
Why I like this approach
There are obviously more sophisticated ways to build this.
I could introduce a database.
I could use a headless CMS.
I could build a separate backend.
I could introduce a queue, webhook system, or dedicated publishing service.
For a large publishing platform, those choices might make perfect sense.
For this project, they would probably be unnecessary complexity.
The interesting part of this architecture is that it uses infrastructure I already needed.
The content is already version-controlled.
GitHub already provides authentication.
GitHub already provides repository history.
GitHub already provides an API for manipulating files.
Vercel already watches the repository and knows how to build the application.
So instead of introducing another system to connect all those pieces, the project increasingly started treating them as one system.
That has been one of the most interesting parts of building it.
What's next?
The development cycle I originally considered "essential" is getting close to complete.
But, naturally, now that the infrastructure exists, there are a few things I'd like to experiment with.
Some of them are practical:
- 📊 A view counter for articles.
- 📄 A way for recruiters to download the professional section of the About page as a properly formatted PDF.
And some are more ambitious:
- 💬 Eventually allowing visitors to authenticate with GitHub.
- ❤️ Letting authenticated visitors like articles.
- 💬 Allowing visitors to comment.
That last category would change the architecture again.
Read-only visitors are easy.
An authenticated reader who can create persistent data is another matter entirely.
Which is probably exactly why it would be interesting to build.
From blog to playground
The funny thing is that the project still isn't really a CMS.
At least, I don't think of it that way.
It's still my personal site.
The MDX files are still there.
The content is still version-controlled.
The deployment is still relatively simple.
But somewhere along the way, the project became a small playground for exploring how application architecture, content management, authentication, Git, APIs, CI/CD, localization, and deployment infrastructure can fit together.
And that has probably been more valuable than the original goal of simply having somewhere to write.
I started with:
"I want a place to publish some articles."
I ended up with:
Admin Dashboard → Content Editor → GitHub → Vercel → Production
And honestly?
For a humble personal website, that's a pretty fun system to have accidentally built.