DEV Community

hieu.dev
hieu.dev

Posted on

Next.js Static Sites and SEO: What I Learned Building Two Tools from Zero to Google Page 1

I built two free browser tools — compressimg.pro (image compressor) and click-thumb.com (thumbnail maker) — and got both ranking on Google within weeks using Next.js static export. Here's the exact SEO setup I used.

Why static export?

Next.js output: 'export' generates a fully static site with no server-side rendering. For tool sites that don't need personalization or real-time data, this is the best SEO choice:

  • Fastest possible LCP — HTML is pre-rendered, no server wait
  • Free hosting on Vercel — static assets served from CDN edge nodes
  • No hydration mismatch errors — pure HTML from the start
// next.config.mjs
const nextConfig = {
  output: 'export',
  compress: true,
  trailingSlash: true, // consistent canonical URLs
  images: { unoptimized: true }, // required for static export
}
Enter fullscreen mode Exit fullscreen mode

trailingSlash: true is important — without it, Next.js generates both /page and /page/ depending on how you link internally. Google treats these as separate URLs and splits impression data between them.

Metadata API — the right way

Next.js 14 Metadata API is much cleaner than manually placing <meta> tags. The key fields that actually matter for SEO:

// app/layout.tsx — site-wide defaults
export const metadata: Metadata = {
  metadataBase: new URL('https://compressimg.pro'),
  title: {
    default: 'Compress Image Online Free | CompressImg',
    template: '%s | CompressImg', // appended to every page title
  },
  robots: {
    index: true,
    follow: true,
    'max-snippet': -1,
    'max-image-preview': 'large',
  },
}
Enter fullscreen mode Exit fullscreen mode
// app/compress-image/layout.tsx — page-specific
export const metadata: Metadata = {
  title: 'Compress Image Online Free – Reduce JPG, PNG, WebP Size',
  description:
    'Free online image compressor. Reduce image file size up to 90% without losing quality.',
  alternates: {
    canonical: 'https://compressimg.pro/compress-image/',
  },
  openGraph: {
    type: 'website',
    url: 'https://compressimg.pro/compress-image/',
    images: [{ url: '/og-image.png', width: 1200, height: 630 }],
  },
}
Enter fullscreen mode Exit fullscreen mode

metadataBase in the root layout lets you use relative image URLs in child layouts — Next.js resolves them to absolute URLs automatically.

Always use a layout.tsx per route for metadata, not page.tsx. The layout approach keeps metadata co-located with the route and makes it easy to add JSON-LD in the same file.

Canonical URLs — the one thing you can't skip

Every page needs a canonical tag. Without it, if Google indexes both https://example.com/page and https://example.com/page/, your impressions and link equity get split.

alternates: {
  canonical: 'https://compressimg.pro/compress-image/',
}
Enter fullscreen mode Exit fullscreen mode

With trailingSlash: true in next.config, Vercel auto-redirects the non-slash version to the slash version. The canonical confirms which URL is authoritative.

JSON-LD structured data

Structured data is the highest-ROI SEO investment for tool sites. Three schemas I use on every page:

WebApplication — tells Google this is a free tool:

{
  "@type": "WebApplication",
  "name": "CompressImg — Free Online Image Compressor",
  "applicationCategory": "MultimediaApplication",
  "offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }
}
Enter fullscreen mode Exit fullscreen mode

FAQPage — targets featured snippet positions:

{
  "@type": "FAQPage",
  "mainEntity": [
    {
      "@type": "Question",
      "name": "Are my images uploaded to a server?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "No. All compression happens 100% in your browser. Your images never leave your device."
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

BreadcrumbList — improves how your URL appears in search results:

{
  "@type": "BreadcrumbList",
  "itemListElement": [
    { "@type": "ListItem", "position": 1, "name": "Home", "item": "https://compressimg.pro" },
    { "@type": "ListItem", "position": 2, "name": "Compress Image", "item": "https://compressimg.pro/compress-image/" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Render all three as a single <script type="application/ld+json"> in the layout:

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      {children}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

The script loading mistake that killed LCP

My first deployment had LCP of 7.1s on mobile 3G. Lighthouse pointed to GA4 and AdSense blocking the critical rendering path.

The fix was a single word change:

// Before: runs when page becomes interactive — competes with LCP paint
<Script src="...gtag/js" strategy="afterInteractive" />

// After: runs only when browser is idle — LCP drops to 2.0s
<Script src="...gtag/js" strategy="lazyOnload" />
Enter fullscreen mode Exit fullscreen mode

afterInteractive sounds safe but it fires during the same period the browser is trying to paint your LCP element. lazyOnload defers to genuine idle time. For analytics and ads, there's no reason to load earlier than idle.

robots.txt and sitemap

For a static site, these are static files in /public:

# public/robots.txt
User-agent: *
Allow: /

Sitemap: https://compressimg.pro/sitemap.xml
Enter fullscreen mode Exit fullscreen mode

For large sites (65+ pages), split into a sitemap index:

<!-- public/sitemap.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <sitemap>
    <loc>https://compressimg.pro/sitemap-0.xml</loc>
  </sitemap>
</sitemapindex>
Enter fullscreen mode Exit fullscreen mode

Submit sitemap.xml to Google Search Console immediately after deploy. Google will crawl all listed URLs within a few days.

Font loading — display: optional vs swap

const inter = Inter({
  subsets: ['latin'],
  variable: '--font-inter',
  display: 'optional', // not 'swap'
})
Enter fullscreen mode Exit fullscreen mode

display: swap causes a FOUT (flash of unstyled text) — the browser renders with a fallback font, then swaps to Inter once loaded. This contributes to CLS.

display: optional tells the browser to use Inter only if it loads before the first paint. If it doesn't, use the system font. No FOUT, no CLS contribution. For body text at normal sizes, users rarely notice the difference.

What actually moves rankings

After 6 weeks:

  • compressimg.pro: 157 keywords ranking, 6 clicks/week, avg position 37
  • click-thumb.com: 40+ pages indexed, first clicks appearing

The technical SEO setup gets you indexed fast and prevents common penalties. But positions 1–10 require content depth (1,000+ words per page, 8+ FAQs) and inbound links from other domains.

The structured data is worth doing from day 1 — it's a one-time setup that Google can use for featured snippets on any question-format query in your niche.

Checklist

✅ output: 'export' in next.config
✅ trailingSlash: true
✅ canonical on every page
✅ metadataBase in root layout
✅ JSON-LD: WebApplication + FAQPage + BreadcrumbList
✅ Script strategy: lazyOnload for analytics/ads
✅ robots.txt with Sitemap URL
✅ sitemap.xml submitted to Search Console
✅ Inter font with display: optional
Enter fullscreen mode Exit fullscreen mode

Full examples live at compressimg.pro and click-thumb.com.

Top comments (0)