Skip to content
Tanvir Ahamed

Full-Stack Development

Shipping a static Next.js site on Firebase Hosting

How this site is built: a Next.js static export served from Firebase Hosting, with URLs, caching and SEO metadata that agree with each other.

By Tanvir Ahamed, published , updated . 4 min read.

この記事を日本語で読む

This site is a Next.js application, but nothing about it runs on a server. Every page is rendered to HTML at build time and uploaded to Firebase Hosting, which serves the files from its CDN. This post walks through the decisions behind that setup and the configuration that holds it together.

Why static

A portfolio and a blog change when I publish something, not when someone visits. Rendering on every request would buy nothing and cost a running server: something to pay for, patch and monitor.

A static export gives me:

  • Complete HTML for every page. Search engines and link previews get the full content without executing JavaScript.
  • No runtime to operate. There is no Node.js process, so there is nothing to scale or keep patched in production.
  • Free hosting. Firebase Hosting's Spark plan covers a site like this, including a custom domain and HTTPS.

The trade-off is that publishing means rebuilding. For a personal site that is fine, and a CI pipeline makes it one push.

Configuring the export

The core of it is three lines in next.config.ts:

next.config.ts
const nextConfig: NextConfig = {
  output: "export",
  trailingSlash: true,
  images: { unoptimized: true },
};

output: "export" makes next build write plain files to out/. Dynamic routes such as /blog/[slug] still work, as long as generateStaticParams lists every slug at build time. I also set dynamicParams = false, so an unknown slug becomes a 404 instead of a build error.

images: { unoptimized: true } is needed because the default image optimizer runs on a server. Instead, images are resized and compressed before they go into public/, and every <img> has explicit width and height so the layout does not shift while they load.

Making URLs agree

The setting that matters most for search is trailingSlash: true. It makes Next.js emit /about/index.html rather than /about.html, and make every internal link point to /about/.

Firebase Hosting has to serve the same shape:

firebase.json
{
  "hosting": {
    "public": "out",
    "cleanUrls": true,
    "trailingSlash": true
  }
}

With both sides agreeing, each page has exactly one URL:

RequestResult
/about/200, serves about/index.html
/about301 redirect to /about/
/about/index.html301 redirect to /about/

If the two disagreed, the canonical URL in a page's metadata could point at an address that redirects. That is a small thing, but it is exactly the kind of inconsistency that makes search engines unsure which URL to index. A helper normalises every path to the trailing-slash form before it goes into a canonical link, the sitemap or structured data.

Caching

Next.js puts JavaScript and CSS under /_next/static/ with a content hash in each filename. A changed file gets a new name, so those files can be cached for a year and marked immutable.

HTML is the opposite. The file at /about/ changes in place with every deploy, so it must be revalidated:

firebase.json (headers)
[
  {
    "source": "**",
    "headers": [{ "key": "Cache-Control", "value": "public, max-age=0, must-revalidate" }]
  },
  {
    "source": "/_next/static/**",
    "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
  }
]

The catch-all rule comes first and the specific rule after it, so hashed assets get the long cache and everything else falls back to revalidation. Firebase also purges its CDN cache on every deploy, so a new release is visible immediately.

SEO metadata at build time

Everything a crawler needs is in the generated HTML:

  • Titles, descriptions and canonical URLs come from the Metadata API, with metadataBase set from an environment variable.
  • sitemap.xml and robots.txt are generated by sitemap.ts and robots.ts, marked force-static so they are written to out/ as ordinary files.
  • Structured data is a JSON-LD graph: a Person and WebSite on every page, BlogPosting on articles, and BreadcrumbList on nested pages.
  • Open Graph images are rendered once per page at build time with next/og.

The canonical origin comes from NEXT_PUBLIC_SITE_URL, and a production build without it fails on purpose. A build that ships canonicals pointing at localhost is worse than a build that stops.

English and Japanese

The site can be read in English and in Japanese. English lives at the root (/about/) and Japanese under /ja/ (/ja/about/).

  • Each language has its own root layout, so <html lang> always matches the page's language.
  • Every page points to its other-language version with hreflang, and x-default points to English. The sitemap carries the same pairs.
  • Articles without a translation get no hreflang at all. An alternate link to a page that doesn't exist is worse than none.

What I left out

Firebase makes it easy to add Firestore, Cloud Functions and Authentication. This site uses none of them. Content lives in typed TypeScript files and MDX in the repository, which gives me version history, review and type checking for free. Contact goes through email and LinkedIn rather than a form, so there is no endpoint that accepts writes from the internet.

If the site ever needs something dynamic, such as a contact form, it can be added as a separate, small service without changing how the pages themselves are built and served.

  • Next.js
  • Firebase Hosting
  • Static sites
  • Technical SEO