All articles
Next.jsPublished 9 min read

How I Built a Bilingual Portfolio with Next.js and TypeScript

A practical walkthrough of the architecture behind nagdista.com: route groups, two root layouts, static export, bilingual SEO, and a bilingual design system.

  • Next.js
  • TypeScript
  • SEO
  • Arabic
  • Architecture

A portfolio is a strange project. It has to be small enough to stay fast, but complete enough to show how you think. It has to work in two languages without feeling like two separate sites. And it has to be easy to update, because the work it describes will keep changing.

This site is the result of those constraints. It is a static Next.js export with English at / and Arabic at /ar, two root layouts, a typed content model, and a design system built around one accent color. Here is how the pieces fit together.

Start with the constraint, not the stack

The first decision was not “which framework?” It was “what must be true when this is finished?” The answers were simple: both languages must be first-class, the site must load quickly on a phone, search engines must be able to read every page, and deployment must not require a server.

That ruled out a client-rendered single-page app for the public pages. It also ruled out any architecture where Arabic was an afterthought added through a translation layer. The language needed to be part of the routing and the layout from the beginning.

Two root layouts, one codebase

Next.js route groups let you organize routes without adding a segment to the URL. I used two groups, (en) and (ar), each with its own root layout. The English group owns /, /about, /projects, and /blog. The Arabic group owns /ar and everything below it.

text
src/app/
  (en)/
    layout.tsx        // <html lang="en" dir="ltr">
    page.tsx          // /
    about/page.tsx    // /about
    projects/page.tsx // /projects
  (ar)/
    layout.tsx        // <html lang="ar" dir="rtl">
    ar/
      page.tsx        // /ar
      about/page.tsx  // /ar/about
      projects/page.tsx

Each root layout loads its own fonts, sets its own lang and dir, and renders the shared header and footer with a locale prop. The duplicated layout is a feature, not a bug: it makes the language boundary explicit and prevents a stray English font or LTR assumption from leaking into the Arabic pages.

One caveat: navigating between routes that use different root layouts triggers a full page load. For a portfolio, that is fine. For an app with frequent language switching, a single [locale] root layout might be a better fit.

A content model that expects two languages

Every piece of content has the same shape in both languages. Instead of scattering conditionals through the UI, I defined a small generic type:

ts
type Locale = "en" | "ar";
type Localized<T> = Record<Locale, T>;

type Project = {
  slug: string;
  stack: string[];
  copy: Localized<{
    tagline: string;
    summary: string;
    overview: string;
    features: string[];
  }>;
};

The compiler now refuses to ship a project that has an English case study but no Arabic one. That is a small thing, but it changes the way you work: translation stops being a cleanup task and becomes part of the definition of done.

Typography is part of the architecture

Arabic and English do not share the same rhythm. English display type can be tight and uppercase. Arabic needs more line height, different letter spacing, and a font that respects the script. I used Space Grotesk and Inter for English, and Alexandria and IBM Plex Sans Arabic for Arabic.

The fonts are loaded through next/font, which self-hosts them and removes the external request. A locale class on the html element swaps two CSS variables, so every component can use font-sans and font-display without knowing which language is active.

css
.locale-en {
  --font-body: var(--font-inter), system-ui, sans-serif;
  --font-heading: var(--font-space-grotesk), system-ui, sans-serif;
}

.locale-ar {
  --font-body: var(--font-ibm-plex-arabic), system-ui, sans-serif;
  --font-heading: var(--font-alexandria), system-ui, sans-serif;
}

SEO beyond the title tag

A bilingual site needs more than translated titles. Each page declares a canonical URL and hreflang alternates for the other language. The home page, about page, and project pages link to their counterparts; the blog articles do not, because the Arabic and English articles are different pieces of writing, not translations.

ts
export const metadata = {
  alternates: {
    canonical: "https://nagdista.com/about/",
    languages: {
      en: "https://nagdista.com/about/",
      ar: "https://nagdista.com/ar/about/",
      "x-default": "https://nagdista.com/about/",
    },
  },
};

On top of the metadata, the site emits a sitemap, a robots file, an RSS feed, llms.txt for AI assistants, and JSON-LD for the person, the website, projects, and articles. None of these guarantees rankings, but they remove ambiguity. Search engines should never have to guess which page is the canonical one or who wrote it.

Keep the client bundle small

Most of the site is server components. The client bundle is limited to the mobile menu, the language switch, the contact form, the GitHub repository filter, and a tiny scroll-reveal observer. Animations are CSS transitions, not a motion library.

That choice matters more than it sounds. Every client component is JavaScript the browser has to download, parse, and execute before the page feels alive. For a content site, the best interaction is often the one that happens in CSS.

What I would do differently

  • Write the Arabic copy earlier. Translating after the layout is finished exposes spacing problems too late.
  • Generate the Open Graph images from one source of truth. Hand-made images drift as the content changes.
  • Test the site with JavaScript disabled. Static HTML should still be readable, navigable, and complete.
  • Keep the number of type scales small. A portfolio does not need twelve heading sizes.

The result

The final site is a static export: no server, no database, no runtime secrets. It can live on any static host. The trade-off is that the GitHub project list is fetched at build time, not on every request. For a portfolio, that is the right trade: the content changes when I deploy, and visitors get HTML instead of a loading spinner.

Building something similar?

I'm always happy to talk through architecture, trade-offs, or a project you're stuck on.

Start a conversation