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:
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:
{
"hosting": {
"public": "out",
"cleanUrls": true,
"trailingSlash": true
}
}With both sides agreeing, each page has exactly one URL:
| Request | Result |
|---|---|
/about/ | 200, serves about/index.html |
/about | 301 redirect to /about/ |
/about/index.html | 301 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:
[
{
"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
metadataBaseset from an environment variable. sitemap.xmlandrobots.txtare generated bysitemap.tsandrobots.ts, markedforce-staticso they are written toout/as ordinary files.- Structured data is a JSON-LD graph: a
PersonandWebSiteon every page,BlogPostingon articles, andBreadcrumbListon 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, andx-defaultpoints to English. The sitemap carries the same pairs. - Articles without a translation get no
hreflangat 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