Portfólio Self-Hosted

typeFull Stack
statusactive
preview
Cover image of Portfólio Self-Hosted

My portfolio is a small CMS: the home page and project pages come from an API that I edit through an admin panel. When cold starts on the free API host started showing visitors a dark screen, I moved the frontend from a Vite SPA to Next.js with Incremental Static Regeneration. Visitors now get cached, fully rendered pages even while the API sleeps, at no extra hosting cost.

Turned a free-tier cold start that showed visitors a dark screen into instantly served cached pages, without paying for an always-on server.

TypeScriptReact 19TursoArgon2Next.JS

highlights

  • Public pages load from cache, so visitors never wait for a sleeping API.
  • Admin edits refresh the cache on demand, with a one hour fallback.
  • Crawlers receive fully rendered HTML instead of an empty shell.
  • Single use refresh tokens that stay safe under concurrent requests.

A portfolio on free hosting that never makes visitors wait

My portfolio is an app, and apps have hosting bills

I think a software engineer's portfolio should be software. Mine is a small full stack app. The home page is built from an API: the hero with my skills, my experience and my projects all come from a database. I change them in an admin panel, and I never touch the code to update the content.

I also wanted it to cost nothing to run. The API and the database sit on free tiers. That worked fine for months, until I opened the site the way a visitor does.

The dark screen nobody should see

The API runs on Render's free plan. After 15 minutes without traffic, the machine goes to sleep. The next request wakes it up, and that takes somewhere between 20 and 50 seconds. That range is my own estimate from using the site, not a measurement.

The frontend was a single page app built with Vite and hosted on Vercel. The browser got an almost empty HTML file, loaded the JavaScript, and only then asked the API for data. When the API was asleep, the visitor looked at a dark screen until it woke up. I even added a terminal style loading animation to fill the gap.

This hurt three things. The first was the person opening the site, often a recruiter with a dozen other tabs. The second was search. Crawlers got a page with no text in it. The third was shared links, which had nothing useful to show.

The cause was simple. Every visit depended on the slowest part of the system being awake at that exact moment.

Before and after

after-before.png?raw=true

Paying with freshness instead of money

There were two fixes on the table. I could pay for a server that never sleeps, or I could stop making visitors depend on the API.

My content changes maybe once a week. Keeping a machine awake all month to return the same JSON felt like the wrong trade. The real problem was timing: the data was fetched at the worst moment, on every visit.

So I moved the frontend to Next.js and used Incremental Static Regeneration. The home page and the public project pages are rendered on the server, stored in Vercel's cache and served from there. A visitor always gets a finished page with all the text in it, even when the API is asleep. Plain server side rendering would not have helped: if every request rendered the page, a sleeping API would still delay it.

The cache refreshes in two ways. When I save something in the admin panel, the API calls a protected endpoint on the frontend that marks the cached pages as stale. The next visit rebuilds them, and since I just made an edit, the API is awake and the rebuild is quick. As a safety net, the pages also expire after one hour. A visitor who hits an expired page still gets the cached copy right away, and Next.js rebuilds it in the background. If that rebuild has to wake the API, the background job waits, not the reader.

This has costs, and I accept them. Content can be up to an hour old if an on demand refresh is missed. A deploy now pays the wake up cost instead of a visitor, because the build asks the API for data. If the API does not answer during the build, the deploy still succeeds, but the home page ships empty until the next refresh, and project pages are rendered on their first visit. That one visitor waits for the API. And when I edit content, I wait for the API to wake up. I am the only person who uses the admin panel, so that is fine.

The admin side stayed a client rendered app that talks to the API directly. It needs live data, and it has one user.

Refresh tokens that work exactly once

Authentication uses a short lived access token and a refresh token, both in httpOnly cookies. The refresh token cookie is scoped to the auth routes, so it does not travel with every request. The database stores a hash of each refresh token, never the token itself.

Every refresh rotates the token. The old one is marked as used and a new one is issued. The detail that matters is how "is it unused?" and "mark it as used" happen. As two separate steps, two requests arriving together could both pass the check and both get a new token. So it is one conditional update:

await db
  .update(refreshToken)
  .set({ usedAt: now })
  .where(
    and(eq(refreshToken.tokenHash, hash),isNull(refreshToken.usedAt))
  ) 
  .returning();

Only one request can win. The other gets nothing back and is rejected.

One refresh, many waiting requests

That rule creates a problem on the client. Admin pages fire several requests at once. When the access token expires, they all fail with 401 together. If each one tried to refresh, only the first would succeed, and the rule above would reject the rest and log me out.

The HTTP client solves this with a small queue. The first 401 starts the refresh. Any request that fails while it runs waits in the queue. When the new token arrives, the queued requests are replayed. If the refresh fails, they all fail together. The server rule and the client queue only make sense as a pair.

Writing in blocks, storing in Markdown

I write project pages in a block editor inside the admin panel. On save, the content is converted to Markdown, so the stored text does not depend on the editor. Public pages render that Markdown, and code blocks marked as Mermaid become diagrams. New projects and experiences start hidden, and I publish them with a toggle when they are ready.

What I would change next

The weakest point is visibility. The public endpoints return every item, drafts included, and the filter runs in the Next.js server before the page is rendered. Visitors never see drafts on the site. But the endpoints are public, so anyone who calls the API directly can read them. It is a trade-off I made to keep the first version simple, and I know its cost. The fix is to split the endpoints: public ones filter by visibility in the database, and the full lists move behind authentication for the admin panel.

Shared links still need work too. The pages now ship real HTML, which helps crawlers, but they do not set per page titles or Open Graph tags yet. Every page has the same generic title. The data for a proper preview is already in the database.

Writing this post also exposed a bug. The pages caught API errors and rendered an empty result, so a failed background rebuild could replace a good cached page with an empty one for an hour. The fix is ready: errors are only swallowed during the build, so a deploy never fails because the API is asleep. At any other time the error is thrown, and Next.js keeps serving the last good version.

None of this needs a bigger server. It needs the same thing the migration needed: choosing when the slow work happens, and keeping it off the visitor's clock.