Different static generation (SSG) behavior on localhost vs. Vercel

Hi all,

I have a project where I show certain data about cities. Since those data rarely change, I want to generate static pages for ca. 1000 cities, using generateStaticParams(). If the requested city is not among the generated pages, the data should be fetched from the database (Supabase) on the fly. So it’s pretty usual SSG / ISG stuff.

My code works on localhost completely fine. I build with “npm run build && npm start“, visit a page in the “static” list, and get the static page instantly. I see no request hitting the DB. I see the following (cache related) response headers:

cache-control s-maxage=86400, stale-while-revalidate=31449600

vary RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch, Accept-Encoding

x-nextjs-cache HIT

x-nextjs-prerender 1

x-nextjs-stale-time 300

But as soon as I deploy it to Vercel, the pages never get served them from static cache. Vercel always generates them dynamically. I always see DB hits after the request. And the response headers show that:

age 0

cache-control public, max-age=0, must-revalidate

server Vercel

strict-transport-security max-age=63072000

vary RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch

x-matched-path /monthly/italy/bolzano/3181913

x-nextjs-prerender 1

x-nextjs-stale-time 300

x-vercel-cache PRERENDER

Do you have any idea what might cause this?

Hey, @kantarse-3906!

Can you share your generateStaticParams() function and the page component structure? Also, can you check your Vercel build logs to see if there are any warnings about dynamic rendering?

Thanks!

Hi Pauline,

thanks for your response. Here is the generateStaticParams() :

export async function generateStaticParams() {
  try {
    
    // Get all popular cities from Supabase
    const staticCities = await getStaticCities();
    
    // Generate parameters for each city - remove the 'monthly' part since it's in the route
    const staticParams = staticCities.map(city => {
      const params = generateCityParams(city);
      // Remove 'monthly' from the slug since it's part of the route path
      return {
        slug: params.slug.slice(0, -1) // Remove last element ('monthly')
      };
    });

    return staticParams;
    
  } catch (error) {
    return []; // Return empty array to prevent build failure
  }
}

// Enable ISR for dynamic routes (non-cached cities)
export const dynamicParams = true;

// Set revalidation time for ISR
export const revalidate = 86400;  // 1 day

getStaticCities() fetches all cities to be considered for static generation from Supabase using supabase-js library.

Here is the main page component…

export default async function MonthlyClimatePageSSG({ params }: PageProps) {
  try {
    const { slug } = await params;
    
    console.log(`[${process.env.NODE_ENV === 'development' ? 'DEV' : 'PROD'}] Processing monthly climate request: /climate/monthly/${slug.join('/')}`);

    // Parse the slug to determine what data to fetch
    const parsedSlug = parseMonthlySlug(slug);
    
    if (!parsedSlug) {
      console.log('Invalid monthly climate route, returning 404');
      notFound();
    }

    let weatherResults: MonthlyWeatherResult[] = [];

    if (parsedSlug.isComparison && parsedSlug.geonameIds) {
      // Handle comparison page
      console.log(`Fetching monthly comparison data for ${parsedSlug.geonameIds.length} cities: ${parsedSlug.geonameIds.join(', ')}`);
      
      try {
        weatherResults = await fetchMonthlyWeatherDataServer(parsedSlug.geonameIds);
        
        if (weatherResults.length === 0) {
          console.log('No monthly weather data found for comparison cities');
          notFound();
        }
      } catch (error) {
        console.error('Error fetching monthly comparison data:', error);
        notFound();
      }
    } else {
      // Handle single city page
      console.log(`Fetching monthly data for single city: ${parsedSlug.geonameId}`);
      
      try {
        weatherResults = await fetchMonthlyWeatherDataServer([parsedSlug.geonameId]);
        
        if (weatherResults.length === 0) {
          console.log('No monthly weather data found for city');
          notFound();
        }
      } catch (error) {
        console.error('Error fetching monthly city data:', error);
        notFound();
      }
    }

    console.log(`Successfully fetched monthly data for ${weatherResults.length} cities`);

    // Add 'monthly' to the slug for the component since it expects it
    const fullSlug = [...slug, 'monthly'];

    // Render the page with server-fetched data
    return (
      <MonthlyClimatePage
        initialWeatherResults={weatherResults}
        slug={fullSlug}
      />
    );

  } catch (error) {
    console.error('Monthly climate page error:', error);
    notFound();
  }
}

Here is the output when building locally

npm run build             

   ▲ Next.js 15.3.4
   - Environments: .env.local
   - Experiments (use with caution):
     ✓ scrollRestoration

   Creating an optimized production build ...
 ✓ Compiled successfully in 3.0s

 ✓ Linting and checking validity of types 
 ✓ Collecting page data    
 ✓ Generating static pages (1033/1033)
 ✓ Collecting build traces    
 ✓ Finalizing page optimization    

Route (app)                                               Size  First Load JS  Revalidate  Expire
┌ ○ /                                                    803 B         266 kB
├ ○ /_not-found                                          220 B         183 kB
├ ƒ /api/blog/[slug]                                     220 B         183 kB
├ ƒ /api/cache                                           220 B         183 kB
├ ƒ /api/cities/[geoname_id]                             220 B         183 kB
...
├ ● /climate/monthly/[...slug]                         2.35 kB         331 kB          1d      1y
├   ├ /climate/monthly/greece/santorini-island/252919                                  1d      1y
├   ├ /climate/monthly/greece/mykonos/257055                                           1d      1y
├   ├ /climate/monthly/greece/mykonos/257056                                           1d      1y
├   └ [+881 more paths]
├ ○ /impressum                                           285 B         207 kB
├ ○ /privacy                                             285 B         207 kB
├ ○ /robots.txt                                          220 B         183 kB
└ ○ /terms                                               285 B         207 kB
+ First Load JS shared by all                           187 kB
  ├ chunks/vendors-27161c75-b4912be9857b0f16.js        12.5 kB
  ├ chunks/vendors-362d063c-3d92fe7d4fe81d44.js        13.1 kB
  ├ chunks/vendors-4a7382ad-e033e48c574f1971.js        11.5 kB
  ├ chunks/vendors-9a66d3c2-68aa9224e4ba509d.js        17.1 kB
  ├ chunks/vendors-ad6a2f20-d6ca7535ea020801.js        11.1 kB
  ├ chunks/vendors-ff30e0d3-899109fc84cf5b3b.js        53.2 kB
  └ other shared chunks (total)                        68.1 kB


○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses generateStaticParams)
ƒ  (Dynamic)  server-rendered on demand

Vercel build logs are almost exactly the same:

[09:31:36.266] Detected Next.js version: 15.3.4
[09:31:36.272] Running "npm run build"
[09:31:36.391]
[09:31:37.236]    ▲ Next.js 15.3.4
[09:31:37.236]    - Experiments (use with caution):
[09:31:37.236]      ✓ scrollRestoration
[09:31:37.236] 
[09:31:37.328]    Creating an optimized production build ...
[09:32:02.586]  ✓ Compiled successfully in 24.0s
[09:32:02.592]    Linting and checking validity of types ...
[09:32:17.356]    Collecting page data ...
[09:32:21.736]    Generating static pages (0/1033) ...
[09:32:42.116]    Generating static pages (258/1033) 
[09:32:54.265]    Generating static pages (516/1033) 
[09:33:06.412]    Generating static pages (774/1033) 
[09:33:17.468]  ✓ Generating static pages (1033/1033)
[09:33:17.525]    Finalizing page optimization ...
[09:33:17.532]    Collecting build traces ...
[09:33:17.542] 
[09:33:17.547] Route (app)                                               Size  First Load JS  Revalidate  Expire
[09:33:17.547] ┌ ○ /                                                    803 B         266 kB
[09:33:17.547] ├ ○ /_not-found                                          220 B         183 kB
[09:33:17.548] ├ ƒ /api/blog/[slug]                                     220 B         183 kB
[09:33:17.548] ├ ƒ /api/cache                                           220 B         183 kB
[09:33:17.548] ├ ƒ /api/cities/[geoname_id]                             220 B         183 kB
...
[09:33:17.549] ├ ● /climate/monthly/[...slug]                         2.35 kB         331 kB          1d      1y
[09:33:17.549] ├   ├ /climate/monthly/greece/santorini-island/252919                                  1d      1y
[09:33:17.550] ├   ├ /climate/monthly/greece/mykonos/257055                                           1d      1y
[09:33:17.550] ├   ├ /climate/monthly/greece/mykonos/257056                                           1d      1y
[09:33:17.550] ├   └ [+881 more paths]
[09:33:17.550] ├ ○ /impressum                                           288 B         207 kB
[09:33:17.550] ├ ○ /privacy                                             288 B         207 kB
[09:33:17.550] ├ ○ /robots.txt                                          220 B         183 kB
[09:33:17.550] └ ○ /terms                                               288 B         207 kB
[09:33:17.550] + First Load JS shared by all                           187 kB
[09:33:17.551]   ├ chunks/vendors-27161c75-b4912be9857b0f16.js        12.5 kB
[09:33:17.551]   ├ chunks/vendors-362d063c-3d92fe7d4fe81d44.js        13.1 kB
[09:33:17.551]   ├ chunks/vendors-4a7382ad-e033e48c574f1971.js        11.5 kB
[09:33:17.551]   ├ chunks/vendors-9a66d3c2-68aa9224e4ba509d.js        17.1 kB
[09:33:17.551]   ├ chunks/vendors-ad6a2f20-d6ca7535ea020801.js        11.1 kB
[09:33:17.551]   ├ chunks/vendors-ff30e0d3-899109fc84cf5b3b.js        53.2 kB
[09:33:17.551]   └ other shared chunks (total)                        68.1 kB
[09:33:17.551] 
[09:33:17.552] 
[09:33:17.552] ○  (Static)   prerendered as static content
[09:33:17.552] ●  (SSG)      prerendered as static HTML (uses generateStaticParams)
[09:33:17.552] ƒ  (Dynamic)  server-rendered on demand
[09:33:17.552] 
[09:33:18.669] Traced Next.js server files in: 77.082ms
[09:33:20.371] Created all serverless functions in: 1.700s
[09:33:20.450] Collected static files (public/, static/, .next/static): 45.777ms
[09:33:22.186] Build Completed in /vercel/output [2m]

So when I visit the “/climate/monthly/greece/mykonos/257055“ from localhost, I get a static page (x-nextjs-cache HIT). But when I visit it from vercel prev or prod domain, I get a dynamically prerendered page (x-vercel-cache PRERENDER).

Let me know if you need any further details for analysis.

Best

@kantarse-3906 I’m jumping in directly and curious to know if you have already installed the Supabase app in Vercel’s integration section with all read/write permissions?

Hi @techchintan, my Supabase DB was already there before I moved my project to next.js (I was using another framework before) and Vercel. So the answer is no, I did not install Supabase through Vercel’s integration section, but I “brought it in”. So I import supabase-js and connect using the DB URL and secret from the environment variables.
Does that impact the SSG behavior?

This rewrite is to address the difference in caching/prerendering behavior between localhost (static) and Vercel (dynamic/prerendered).

The most common cause is that generateStaticParams is not returning the expected params at build time on Vercel, or the route is not being statically generated as intended.

To ensure static generation, we explicitly set export const dynamic = "error" to force static generation, and ensure generateStaticParams is correct.

Remove dynamicParams and revalidate to ensure static generation only

Conclusion:

  1. By adding export const dynamic = "error";, we instruct Next.js to only statically generate this route at build time.
  2. This prevents Vercel from falling back to dynamic rendering (PRERENDER) for any route not generated at build time.
  3. If a user requests a route that was not statically generated, Next.js will throw an error instead of rendering it on-demand.
  4. This ensures that, just like on localhost, only the routes returned by generateStaticParams are available and statically generated.
  5. Removing dynamicParams and revalidate also disables ISR and dynamic fallback, further enforcing static-only behavior.

@kantarse-3906 - Try this, this will help you to solve this problem. if not then please share page.tsx also. I want to check end to end implementation, as this problem looks strange to me. I never faced such problems.

Best,
Chintan

[chintan.com]

@techchintan just removing dynamicParams and revalidate and introducing export const dynamic = "error" did not ensure static generation. I kept getting dynamically generated pages for the paths that are not in the list of “popular cities” (staticParams) with a response header x-nextjs-cache: MISS (localhost) as well as x-vercel-cache: MISS (vercel).

Only after explicitly setting export const dynamicParams = false, I started getting 404 errors for the paths that are not in the staticParams list. But even after that, for the paths that are in the staticParams list, I keep getting responses with x-vercel-cache: PRERENDER on vercel, while getting x-nextjs-cache: HIT on localhost.

I need to set it straight though that in my original post I had said that “I always see DB hits after the request“. This is not true. I do not see DB a hit after the request with PRERENDER response. The only culprit is that it’s way too slow. The response with PRERENDER has “Waiting for server response” = 360 ms, while for the subsequent responses with HIT are 40 ms.

Here is the page.tsx - I tried to shorten it for brevity as much as possible.

import React from 'react';
import { supabase } from '@/lib/database';
import type { City } from '@/lib/supabase';
import { getStaticCities, createCitySlug } from '@/lib/monthly-weather-server';

// Monthly aggregate data structure from monthly_aggregates view
interface MonthlyAggregate {
  geoname_id: number;
  month: number;
  tmax_mean: number | null;
  tmin_mean: number | null;
}

// Page params interface
interface PageParams {
  country: string;
  city: string;
  geoname_id: string;
}

export const dynamic = "error"
export const dynamicParams = false

// Server-side function to fetch city data by geoname_id
async function getCityData(geonameId: number): Promise<City | null> {
  try {
    
    const { data, error } = await supabase
      .from('cities')
      .select(`
        geoname_id,
        name,
        country_code,
        country_name
      `)
      .eq('geoname_id', geonameId)
      .single();

    if (error) {
      return null;
    }

    return data as City;
  } catch (error) {
    return null;
  }
}

// Server-side function to fetch monthly weather data by geoname_id
async function getMonthlyWeatherData(geonameId: number): Promise<MonthlyAggregate[]> {
  try {
    
    const { data, error } = await supabase
      .from('monthly_aggregates')
      .select(`
        geoname_id,
        month,
        tmax_mean,
        tmin_mean
      `)
      .eq('geoname_id', geonameId)
      .order('month');

    if (error) {
      throw new Error('Failed to fetch aggregates');
    }

    return data as MonthlyAggregate[] || [];
  } catch (error) {
    console.error('Error fetching monthly weather data:', error);
    throw error;
  }
}

// Generate static params for popular cities
export async function generateStaticParams(): Promise<PageParams[]> {
  try {
    
    // Get all cities from supabase table "popular_cities" for build-time generation
    const cities = await getStaticCities();
    
    const params: PageParams[] = cities.map(city => ({
      country: city.country_code.toLowerCase(),
      city: createCitySlug(city.name),  // just removes special characters and trims
      geoname_id: city.geoname_id.toString()
    }));

    return params;
  } catch (error) {
    console.error('Error generating static params:', error);
    // Return empty array to prevent build failure
    return [];
  }
}

// Helper function to get month name
function getMonthName(month: number): string {
  const months = [
    'January', 'February', 'March', 'April', 'May', 'June',
    'July', 'August', 'September', 'October', 'November', 'December'
  ];
  return months[month - 1] || `Month ${month}`;
}

// Helper function to format temperature
//... omitting

// Main page component - data fetching happens at build time due to static generation
export default async function MonthlyTestPage({
  params
}: {
  params: Promise<PageParams>;
}) {
  const { country, city, geoname_id } = await params;
  const geonameId = parseInt(geoname_id, 10);

  try {
    const [cityData, weatherData] = await Promise.all([
      getCityData(geonameId),
      getMonthlyWeatherData(geonameId)
    ]);

  // ... omitting style definitions

  return (
    <div style={containerStyle}>
      <div style={cityInfoStyle}>
        <h1 style={cityTitleStyle}>{`Monthly Weather Data - ${cityData.name}, ${cityData.country_name}`}</h1>
        <p style={cityParaStyle}><strong>Country:</strong> {`${cityData.country_name} (${cityData.country_code})`}</p>
        <p style={cityParaStyle}><strong>Geoname ID:</strong> {cityData.geoname_id}</p>
      </div>

      {weatherData.length > 0 ? (
        <table style={tableStyle}>
          <thead>
            <tr>
              <th style={headerStyle}>Month</th>
              <th style={headerStyle}>Max Temp</th>
              <th style={headerStyle}>Min Temp</th>
            </tr>
          </thead>
          <tbody>
            {weatherData.map((data) => (
              <tr key={data.month}>
                <td style={cellStyle}>{getMonthName(data.month)}</td>
                <td style={cellStyle}>{formatTemp(data.tmax_mean)}</td>
                <td style={cellStyle}>{formatTemp(data.tmin_mean)}</td>
              </tr>
            ))}
          </tbody>
        </table>
      ) : (
        <p>No monthly data available for this city.</p>
      )}

    </div>
  );

  } catch (error) {
    return (
      <div>
        <h1>Error Loading Data</h1>
        <p>Failed to load city or weather data. Please try again later.</p>
      </div>
    );
  }
}

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.