Why I built my portfolio with TanStack Start and Strapi
The stack behind this site and the reasons for it: typed routes, server functions, a CMS I host myself, and three languages from day one.
Dahmane Abdelmalek
Oct 20, 20263 min readPart 1 of 3
A portfolio is a small website, and it's tempting to pick a template and move on. I wanted mine to be a real project instead: the kind of codebase I'd be happy to hand over to a client. It had to present my work in English, Arabic and French, let me change any text without touching code, and stay cheap to run on my own server.
This series walks through how it's built. This first part covers the two biggest decisions: TanStack Start for the website, and Strapi for the content.
The shape of the project
One repository holds two apps, managed with pnpm and Turborepo:
apps/webis the website: TanStack Start, TanStack Router and TanStack Query, styled with Tailwind CSS.apps/cmsis Strapi 5, where every text, project and job lives, in all three languages.
Both run as Docker containers on my own VPS, deployed with Dokploy. No platform lock-in, and one predictable monthly bill.
Why TanStack Start
Most of my client projects use Next.js, so this was a deliberate change. Three things won me over.
Routes are typed. A link to a page that doesn't exist fails to compile. Every address on this site starts with a language (/en, /ar, /fr), and the type checker makes sure no link forgets it.
Server functions validate their input. Everything that reads from the CMS runs in a server function, with a Zod schema for its arguments. The browser never talks to Strapi directly and never learns its internal address.
export const getProjects = createServerFn({ method: 'GET' })
.validator(z.object({ lang: languageSchema }))
.handler(async ({ data }) => {
const response = await getStrapiClient()
.collection('projects')
.find({
locale: data.lang,
sort: ['order:asc', 'title:asc'],
populate: CARD_POPULATE,
})
return projectListSchema.parse(response).data
})Loading data composes. Each route's loader asks TanStack Query for exactly what the page needs. Pages that need the same data share the cached result: the home page and the projects page both read the project list, so going from one to the other costs no new request.
export const Route = createFileRoute('/$lang/projects/')({
validateSearch: projectsSearchSchema,
loader: ({ context, params }) =>
context.queryClient.query({
...projectsQuery(params.lang),
staleTime: 'static',
}),
component: ProjectsPage,
})The trade-off is maturity. Next.js has years of answers on every forum; TanStack Start is younger, and a few times I read its source code instead of a blog post. For a site I maintain myself, the type safety was worth it.
Why Strapi
For the content I wanted a real editing interface, proper translations, and self-hosting. Strapi 5 covers all three:
- Translations per field. Each text exists in English, Arabic and French, while dates, links and images are shared between languages.
- Draft and publish. I can prepare a project or an article and publish it when it's ready. The site only ever requests published content.
- Custom endpoints where they matter. Comments and contact messages come from the public, so they go through small custom routes with rate limits instead of the default API.
The content model mirrors the site: a profile, jobs, degrees, projects, services, and now articles, tags and series. When I add a project in the admin panel, it appears on the site without a new deployment.
What I'd do differently
Settle the design before building pages. I built the first pages, then redesigned them twice. Now the approved design lives in a document in the repository, and every page is built from it.
Part 2 covers the part of that design I care about most: making Arabic a first-class language rather than a mirrored afterthought.
Newsletter
Get new articles by email
One email per new article. Unsubscribe anytime.
Prefer a feed reader? RSS feed
Responses
Contact
Have a project in mind?
Let's build it.
Open to remote roles and freelance projects.