On a small project, TypeScript can look like annotations around code that already works. It started to matter in this portfolio when content, routes and metadata no longer fitted on one page. A title is not just text. It can end up in a blog card, the document title, structured data, the sitemap and two languages.
I do not use TypeScript to make the project sound more complicated than it is. I use it so that certain inconsistencies surface during development or the build, while I can still fix them without reviewing every public route by hand.
Types do not replace review. They define the data each part of the site needs and flag when the code stops respecting that agreement.
Content stops being an unstructured block of text
Posts and projects are written as MDX, but they have a defined shape before rendering in src/content.config.ts. For posts, the schema requires a title, summary and publication date. It also defines fields the system uses for tags, English versions, SEO, social images and reading time.
const posts = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
title_en: z.string().optional(),
seoTitle: z.string().optional(),
seoTitle_en: z.string().optional(),
summary: z.string(),
summary_en: z.string().optional(),
ogImage: z.string().optional(),
ogImage_en: z.string().optional(),
tags: z.array(z.string()).default([]),
tags_en: z.array(z.string()).optional(),
publishedAt: z.coerce.date(),
updatedAt: z.coerce.date().optional(),
draft: z.boolean().default(false),
readingTime: z.number().optional(),
}),
})The useful part is not that the file ends in .ts. Every entry passes through the same contract. If I forget publishedAt, I do not find out when someone visits a URL with no date. The build rejects the entry. If I write an impossible date, the failure appears in the content that needs correcting.
The same data then feeds the visible post header, BlogPosting, and sitemap lastmod. I do not have to remember in three places that a piece was updated. The data exists once, and the layers that use it receive it from the collection.
Astro’s Content Collections documentation describes this validation as part of a content system. In this project it protects me more often from an incomplete entry than from a dramatic syntax error.
An optional field is a decision too
The schema allows title_en and summary_en to be optional. That leaves room for historical entries and allows a Spanish fallback when a translation is not ready. It prevents a route from breaking because one field is missing.
It also exposes a limit. Marking a property optional means consuming code must allow it to be absent. I cannot claim a website is bilingual because I added title_en?: string. I still have to write both versions, review their links and open the published English page.
The correct type is not always the strictest possible type. Sometimes an optional property is hiding a requirement that should become mandatory before publication. It depends on who edits content, which review process exists, and what version can reach production.
In how I organise bilingual content with Astro and MDX, I explain the convention I use here. TypeScript helps with frontmatter fields, but it cannot judge whether a translation preserves the original intent.
Component props are a boundary too
Types become just as useful when a component is reused. A project card does not receive an object that vaguely resembles a project. It receives a real entry from the projects collection.
import type { CollectionEntry } from 'astro:content'
interface Props {
project: CollectionEntry<'projects'>
featured?: boolean
variant?: 'grid' | 'archive'
index?: number
}With that contract, ProjectCard knows title, stack and year follow the project schema, and that thumbnail, when present, is a string. Code using it cannot accidentally pass a blog post or an improvised object with only a title and image.
It also limits variants to grid and archive. That sounds minor until the component appears in several listings. Without the limit, someone can write variant="featured", the page will still produce HTML, and the component will no longer represent what the design expected.
The type does not turn a component into a closed box. When a new variant is useful, it still needs designing, adding to the contract and checking for responsive, accessibility and content changes. The type stops invented names. It does not make that decision for me.
Astro recommends declaring a Props interface in component frontmatter. It is a simple pattern, but it prevents a component contract from being hidden among conditions and default values. See its TypeScript guide.
Metadata has too many destinations to rely on memory
A page needs a title, description, image, canonical, language alternates and, when appropriate, dates for social metadata and structured data. Copying those values into every template would be a quick way for an English URL to point at a Spanish image, or for a description to change in one place but not another.
That is why Seo.astro defines what it can receive:
interface Props {
title?: string
description?: string
image?: string
type?: 'website' | 'article'
publishedTime?: string
updatedTime?: string
noindex?: boolean
siteName?: string
}A template cannot pass type="post" and expect it to mean article. The editor flags it first. It also cannot confuse a date with a boolean or an image with an object. These are quiet errors, but they multiply across posts, projects, hubs, service pages and two languages.
On post pages, collection data builds BlogPosting, while the route builds the canonical from the slug. That separation keeps one rule clear: content defines what a page is about, and the template defines how it is published and described outside the site.
That is part of the technical SEO architecture of this portfolio. TypeScript reduces inconsistencies inside the project. It cannot guarantee that a description matches a search, or that Google selects the declared canonical.
Types support routes but do not replace a language map
The project keeps es and en locales where they are useful. Language helpers then resolve the routes and the matching <html lang>. A card builds its link from the current locale and a collection identifier instead of receiving a handwritten URL every time.
That removes one class of mistakes and makes clearer which part of the system owns each job. It does not mean internationalisation is solved because a union type exists. Spanish and English share one MDX file here, and the build removes the block that does not belong on each final page. That convention needs tests against generated HTML.
Types cannot tell whether English copy says what it should, or whether an example works for another audience. The benefit is dividing responsibilities. The type limits possible values, the helper builds the route, the template publishes hreflang and canonicals, and the build checks the result.
The important check happens before deployment
A project can have types and still fail while building. That is why I do not stop at editor autocomplete. pnpm check runs astro check over .astro and .ts files. Then pnpm build generates the static routes. Tests inspect HTML in dist for schema, canonicals, hreflang, internal links and service pages.
The order I use is:
- The schema rejects incomplete content data.
- Typed props catch incompatible component use.
astro checkfinds issues between Astro and TypeScript.- The build proves routes can be generated.
- Output tests check things types cannot see.
It is not an infallible chain. An external link can fail later, a hosting rule can change a header, or someone can find text confusing. Reaching production with those layers reviewed is still different from trusting that a page “looks fine” locally.
What TypeScript does not fix for me
Typing is sometimes treated as another word for quality. It is not.
TypeScript does not check whether a project budget is reasonable, whether copy answers a useful question, or whether generic alt text is accessible. It does not protect form data on the server. It also cannot validate an API response unless I validate it where it enters the system.
It does not stop questionable decisions in this portfolio either. The current schema permits optional fields and one bilingual MDX entry. Those choices reduce friction here, but they could be poor choices for an editorial team needing drafts, language-by-language review, or independent publishing.
The value of TypeScript is not a promise that errors disappear. It makes existing assumptions explicit. When one changes, the editor, the check or the build have a better chance of showing the places I need to review.
That is why I use it with Astro. It does not make a website professional on its own. It helps keep content, components and metadata aligned while a project stops being one page and starts having consequences across several routes.
You can see those decisions applied to pages, projects and bilingual content in my projects or in how I rebuilt this portfolio with Astro.