Every project is an on going one...

Git, APIs, CI/CD, localization, and deployment infrastructure can fit together.

GithubBlogGithubAPI

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:

text
Admin Dashboard


Content Editor


Content API


Content Layer


GitHub Contents API


Repository

And then Vercel takes over:

text
GitHub

   │ repository change

Vercel


New deployment


New production build

Which 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:

text
post.mdx

It was a logical document containing multiple translations.

For example:

text
content/
  blog/
    my-post/
      en.mdx
      pt.mdx

The 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:

text
ContentDocument

      ├── en
      │    └── content/blog/my-post/en.mdx

      └── pt
           └── content/blog/my-post/pt.mdx

Each 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:

text
D:/alexandre-blog/content/blog/my-post/en.mdx

A GitHub repository path should look like:

text
content/blog/my-post/en.mdx

Those 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:

text
Content domain

      │ repository operation

GitHub integration


GitHub API

The 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:

text
content/blog/my-post/en.mdx

You 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:

text
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:

text
content/blog/my-post/en.mdx

serializes 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:

text
Editor


Find existing file


Get SHA


Serialize updated content


Update file


Create Git commit

For a new file:

text
Editor


Check whether file exists


Serialize content


Create file


Create Git commit

And 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:

text
Content Editor


GitHub commit


GitHub history


Admin Dashboard

The 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:

text
feat/publishing-system-preview-and-production

while Production points to:

text
master

The application code doesn't need to know which branch it is operating against.

The environment configuration decides.

Conceptually:

text
Preview

   └── GITHUB_REPOSITORY_BRANCH


   Preview publishing branch


Production

   └── GITHUB_REPOSITORY_BRANCH


   master

That 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:

text
D:/alexandre-blog/content/blog/...

instead of:

text
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:

text
D:/alexandre-blog/content/blog/blog-github-post/pt.mdx

and 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:

bash
git push --force

would 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:

text
                    ┌──────────────────┐
                    │  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.