diff --git a/cypress/fixtures/pages/getStaticProps/withFallback/[id].js b/cypress/fixtures/pages/getStaticProps/withFallback/[id].js
new file mode 100644
index 0000000..fa542dd
--- /dev/null
+++ b/cypress/fixtures/pages/getStaticProps/withFallback/[id].js
@@ -0,0 +1,62 @@
+import { useRouter } from 'next/router'
+import Link from 'next/link'
+
+const Show = ({ show }) => {
+ const router = useRouter()
+
+ // This is never shown on Netlify. We just need it for NextJS to be happy,
+ // because NextJS will render a fallback HTML page.
+ if (router.isFallback) {
+ return
Loading...
+ }
+
+ return (
+
+
+ This page uses getStaticProps() to pre-fetch a TV show.
+
+
+
+
+
Show #{show.id}
+
+ {show.name}
+
+
+
+
+
+
Go back home
+
+
+ )
+}
+
+export async function getStaticPaths() {
+ // Set the paths we want to pre-render
+ const paths = [
+ { params: { id: '3' } },
+ { params: { id: '4' } }
+ ]
+
+ // We'll pre-render these paths at build time.
+ // { fallback: true } means other routes will be rendered at runtime.
+ return { paths, fallback: true }
+}
+
+
+export async function getStaticProps({ params }) {
+ // The ID to render
+ const { id } = params
+
+ const res = await fetch(`https://api.tvmaze.com/shows/${id}`);
+ const data = await res.json();
+
+ return {
+ props: {
+ show: data
+ }
+ }
+}
+
+export default Show
diff --git a/cypress/fixtures/pages/index.js b/cypress/fixtures/pages/index.js
index e1d3ddc..da21fe8 100644
--- a/cypress/fixtures/pages/index.js
+++ b/cypress/fixtures/pages/index.js
@@ -101,15 +101,30 @@ const Index = ({ shows }) => (
-
+
getStaticProps/1 (dynamic route)
-
+
getStaticProps/2 (dynamic route)
+
+
+ getStaticProps/withFallback/3 (dynamic route)
+
+
+
+
+ getStaticProps/withFallback/4 (dynamic route)
+
+
+
+
+ getStaticProps/withFallback/75 (dynamic route, not pre-rendered)
+
+
5. Static Pages Stay Static
diff --git a/cypress/integration/default_spec.js b/cypress/integration/default_spec.js
index 04f6c45..656a5b1 100644
--- a/cypress/integration/default_spec.js
+++ b/cypress/integration/default_spec.js
@@ -138,53 +138,107 @@ describe('getStaticProps', () => {
// Navigate to page and test that no reload is performed
// See: https://glebbahmutov.com/blog/detect-page-reload/
cy.contains('getStaticProps/static').click()
- cy.window().should('have.property', 'noReload', true)
-
cy.get('h1').should('contain', 'Show #71')
cy.get('p').should('contain', 'Dancing with the Stars')
+ cy.window().should('have.property', 'noReload', true)
})
})
- context('with dynamic route and no fallback', () => {
- it('loads shows 1 and 2', () => {
- cy.visit('/getStaticProps/1')
- cy.get('h1').should('contain', 'Show #1')
- cy.get('p').should('contain', 'Under the Dome')
+ context('with dynamic route', () => {
+ context('without fallback', () => {
+ it('loads shows 1 and 2', () => {
+ cy.visit('/getStaticProps/1')
+ cy.get('h1').should('contain', 'Show #1')
+ cy.get('p').should('contain', 'Under the Dome')
+
+ cy.visit('/getStaticProps/2')
+ cy.get('h1').should('contain', 'Show #2')
+ cy.get('p').should('contain', 'Person of Interest')
+ })
- cy.visit('/getStaticProps/2')
- cy.get('h1').should('contain', 'Show #2')
- cy.get('p').should('contain', 'Person of Interest')
- })
+ it('loads page props from data .json file when navigating to it', () => {
+ cy.visit('/')
+ cy.window().then(w => w.noReload = true)
- it('loads page props from data .json file when navigating to it', () => {
- cy.visit('/')
- cy.window().then(w => w.noReload = true)
+ // Navigate to page and test that no reload is performed
+ // See: https://glebbahmutov.com/blog/detect-page-reload/
+ cy.contains('getStaticProps/1').click()
- // Navigate to page and test that no reload is performed
- // See: https://glebbahmutov.com/blog/detect-page-reload/
- cy.contains('getStaticProps/1').click()
- cy.window().should('have.property', 'noReload', true)
+ cy.get('h1').should('contain', 'Show #1')
+ cy.get('p').should('contain', 'Under the Dome')
- cy.get('h1').should('contain', 'Show #1')
- cy.get('p').should('contain', 'Under the Dome')
+ cy.contains('Go back home').click()
+ cy.contains('getStaticProps/2').click()
- cy.contains('Go back home').click()
- cy.contains('getStaticProps/2').click()
+ cy.get('h1').should('contain', 'Show #2')
+ cy.get('p').should('contain', 'Person of Interest')
- cy.get('h1').should('contain', 'Show #2')
- cy.get('p').should('contain', 'Person of Interest')
+ cy.window().should('have.property', 'noReload', true)
+ })
+
+ it('returns 404 when trying to access non-defined path', () => {
+ cy.request({
+ url: '/getStaticProps/3',
+ failOnStatusCode: false
+ }).then(response => {
+ expect(response.status).to.eq(404)
+ cy.state('document').write(response.body)
+ })
+
+ cy.get('h2').should('contain', 'This page could not be found.')
+ })
})
- it('returns 404 when trying to access non-defined path', () => {
- cy.request({
- url: '/getStaticProps/3',
- failOnStatusCode: false
- }).then(response => {
- expect(response.status).to.eq(404)
- cy.state('document').write(response.body)
+ context('with fallback', () => {
+ it('loads pre-rendered TV shows 3 and 4', () => {
+ cy.visit('/getStaticProps/withFallback/3')
+ cy.get('h1').should('contain', 'Show #3')
+ cy.get('p').should('contain', 'Bitten')
+
+ cy.visit('/getStaticProps/withFallback/4')
+ cy.get('h1').should('contain', 'Show #4')
+ cy.get('p').should('contain', 'Arrow')
+ })
+
+ it('loads non-pre-rendered TV show', () => {
+ cy.visit('/getStaticProps/withFallback/75')
+
+ cy.get('h1').should('contain', 'Show #75')
+ cy.get('p').should('contain', 'The Mindy Project')
})
- cy.get('h2').should('contain', 'This page could not be found.')
+ it('loads non-pre-rendered TV shows when SSR-ing', () => {
+ cy.ssr('/getStaticProps/withFallback/75')
+
+ cy.get('h1').should('contain', 'Show #75')
+ cy.get('p').should('contain', 'The Mindy Project')
+ })
+
+ it('loads page props from data .json file when navigating to it', () => {
+ cy.visit('/')
+ cy.window().then(w => w.noReload = true)
+
+ // Navigate to page and test that no reload is performed
+ // See: https://glebbahmutov.com/blog/detect-page-reload/
+ cy.contains('getStaticProps/withFallback/3').click()
+
+ cy.get('h1').should('contain', 'Show #3')
+ cy.get('p').should('contain', 'Bitten')
+
+ cy.contains('Go back home').click()
+ cy.contains('getStaticProps/withFallback/4').click()
+
+ cy.get('h1').should('contain', 'Show #4')
+ cy.get('p').should('contain', 'Arrow')
+
+ cy.contains('Go back home').click()
+ cy.contains('getStaticProps/withFallback/75').click()
+
+ cy.get('h1').should('contain', 'Show #75')
+ cy.get('p').should('contain', 'The Mindy Project')
+
+ cy.window().should('have.property', 'noReload', true)
+ })
})
})
})
diff --git a/lib/allNextJsPages.js b/lib/allNextJsPages.js
index 47fedc1..76efb96 100644
--- a/lib/allNextJsPages.js
+++ b/lib/allNextJsPages.js
@@ -12,7 +12,7 @@ const getAllPages = () => {
)
// Read prerender manifest that tells us which SSG pages exist
- const { routes: ssgPages, dynamicRoutes: dynamicSsgPages } = readJSONSync(
+ const { routes: staticSsgPages, dynamicRoutes: dynamicSsgPages } = readJSONSync(
join(NEXT_DIST_DIR, "prerender-manifest.json")
)
@@ -23,7 +23,7 @@ const getAllPages = () => {
return
// Skip page if it is actually an SSG page
- if(route in ssgPages || route in dynamicSsgPages)
+ if(route in staticSsgPages || route in dynamicSsgPages)
return
// Check if we already have a page pointing to this file
@@ -40,9 +40,18 @@ const getAllPages = () => {
})
// Parse SSG pages
- Object.entries(ssgPages).forEach(([ route, { dataRoute }]) => {
+ Object.entries(staticSsgPages).forEach(([ route, { dataRoute }]) => {
pages.push(new Page({ route, type: "ssg", dataRoute }))
})
+ Object.entries(dynamicSsgPages).forEach(([ route, { dataRoute, fallback }]) => {
+ // Ignore pages without fallback, these are already handled by the
+ // static SSG page block above
+ if(fallback === false)
+ return
+
+ const filePath = join("pages", `${route}.js`)
+ pages.push(new Page({ route, filePath, type: "ssg-fallback", alternativeRoutes: [dataRoute] }))
+ })
return pages
}
@@ -66,6 +75,10 @@ class Page {
return this.type === "ssg"
}
+ isSsgFallback() {
+ return this.type === "ssg-fallback"
+ }
+
// Return route and alternative routes as array
get routesAsArray() {
return [this.route, ...this.alternativeRoutes]
diff --git a/lib/setupRedirects.js b/lib/setupRedirects.js
index 8f86d76..9b9de6e 100644
--- a/lib/setupRedirects.js
+++ b/lib/setupRedirects.js
@@ -19,7 +19,7 @@ const setupRedirects = () => {
// Sort dynamic pages by route: More-specific routes precede less-specific
// routes
- const dynamicRoutes = dynamicPages.map(page => page.routesAsArray).flat()
+ const dynamicRoutes = dynamicPages.map(page => page.route)
const sortedDynamicRoutes = getSortedRoutes(dynamicRoutes)
const sortedDynamicPages = dynamicPages.sort((a, b) => (
sortedDynamicRoutes.indexOf(a.route) - sortedDynamicRoutes.indexOf(b.route)
@@ -44,6 +44,10 @@ const setupRedirects = () => {
else if (page.isSsg()) {
to = `${page.route}.html`
}
+ // SSG fallback pages (for non pre-rendered paths)
+ else if (page.isSsgFallback()) {
+ to = `/.netlify/functions/${getNetlifyFunctionName(page.filePath)}`
+ }
// Pre-rendered HTML pages
else if (page.isHtml()) {
to = `/${path.relative("pages", page.filePath)}`
diff --git a/lib/setupSsgPages.js b/lib/setupSsgPages.js
index e51976a..2ee4cba 100644
--- a/lib/setupSsgPages.js
+++ b/lib/setupSsgPages.js
@@ -1,22 +1,24 @@
-const path = require('path')
-const { join } = path
-const { copySync, existsSync, readJSONSync } = require('fs-extra')
-const { NEXT_DIST_DIR, NETLIFY_PUBLISH_PATH } = require('./config')
+const path = require('path')
+const { join } = path
+const { copySync, existsSync } = require('fs-extra')
+const { NEXT_DIST_DIR, NETLIFY_PUBLISH_PATH,
+ NETLIFY_FUNCTIONS_PATH,
+ FUNCTION_TEMPLATE_PATH } = require('./config')
+const allNextJsPages = require('./allNextJsPages')
+const getNetlifyFunctionName = require('./getNetlifyFunctionName')
// Identify all pages that require server-side rendering and create a separate
// Netlify Function for every page.
const setupSsgPages = () => {
console.log(`\x1b[1m🔥 Setting up SSG pages\x1b[22m`)
- // Read prerender manifest that tells us which SSG pages exist
- const { routes } = readJSONSync(
- join(NEXT_DIST_DIR, "prerender-manifest.json")
- )
+ // Get SSG pages
+ const ssgPages = allNextJsPages.filter(page => page.isSsg())
// Copy pre-rendered SSG pages to Netlify publish folder
console.log(" ", "1. Copying pre-rendered SSG pages to", NETLIFY_PUBLISH_PATH)
- Object.keys(routes).forEach(route => {
+ ssgPages.forEach(({ route }) => {
const filePath = join("pages", `${route}.html`)
console.log(" ", " ", filePath)
@@ -34,7 +36,7 @@ const setupSsgPages = () => {
const nextDataFolder = join(NETLIFY_PUBLISH_PATH, "_next", "data/")
console.log(" ", "2. Copying SSG page data to", nextDataFolder)
- Object.entries(routes).forEach(([route, { dataRoute }]) => {
+ ssgPages.forEach(({ route, dataRoute }) => {
const dataPath = join("pages", `${route}.json`)
console.log(" ", " ", dataPath)
@@ -47,6 +49,38 @@ const setupSsgPages = () => {
}
)
})
+
+ // Set up Netlify Functions to handle fallbacks for SSG pages
+ const ssgFallbackPages = allNextJsPages.filter(page => page.isSsgFallback())
+ console.log(" ", "3. Setting up Netlify Functions for SSG pages with fallback: true")
+
+ ssgFallbackPages.forEach(({ filePath }) => {
+ console.log(" ", " ", filePath)
+
+ // Set function name based on file path
+ const functionName = getNetlifyFunctionName(filePath)
+ const functionDirectory = join(NETLIFY_FUNCTIONS_PATH, functionName)
+
+ // Copy function template
+ copySync(
+ FUNCTION_TEMPLATE_PATH,
+ join(functionDirectory, `${functionName}.js`),
+ {
+ overwrite: false,
+ errorOnExist: true
+ }
+ )
+
+ // Copy page
+ copySync(
+ join(NEXT_DIST_DIR, "serverless", filePath),
+ join(functionDirectory, "nextJsPage.js"),
+ {
+ overwrite: false,
+ errorOnExist: true
+ }
+ )
+ })
}
module.exports = setupSsgPages
diff --git a/tests/__snapshots__/defaults.test.js.snap b/tests/__snapshots__/defaults.test.js.snap
index 26dda2b..7e4af85 100644
--- a/tests/__snapshots__/defaults.test.js.snap
+++ b/tests/__snapshots__/defaults.test.js.snap
@@ -8,10 +8,14 @@ exports[`Routing creates Netlify redirects 1`] = `
/static /static.html 200
/404 /404.html 200
/getStaticProps/static /getStaticProps/static.html 200
+/getStaticProps/withFallback/3 /getStaticProps/withFallback/3.html 200
+/getStaticProps/withFallback/4 /getStaticProps/withFallback/4.html 200
/getStaticProps/1 /getStaticProps/1.html 200
/getStaticProps/2 /getStaticProps/2.html 200
/api/shows/:id /.netlify/functions/next_api_shows_id 200
/api/shows/* /.netlify/functions/next_api_shows_params 200
+/getStaticProps/withFallback/:id /.netlify/functions/next_getStaticProps_withFallback_id 200
+/_next/data/$path$/getStaticProps/withFallback/:id.json /.netlify/functions/next_getStaticProps_withFallback_id 200
/shows/:id /.netlify/functions/next_shows_id 200
/shows/* /.netlify/functions/next_shows_params 200
/static/:id /static/[id].html 200"
diff --git a/tests/customNextDistDir.test.js b/tests/customNextDistDir.test.js
index ec7077b..583e75e 100644
--- a/tests/customNextDistDir.test.js
+++ b/tests/customNextDistDir.test.js
@@ -45,8 +45,8 @@ beforeAll(
const { stdout } = await npmRunBuild({ directory: PROJECT_PATH })
BUILD_OUTPUT = stdout
},
- // time out after 60 seconds
- 60 * 1000
+ // time out after 180 seconds
+ 180 * 1000
)
describe('Next', () => {
diff --git a/tests/defaults.test.js b/tests/defaults.test.js
index a5fab1c..caafcce 100644
--- a/tests/defaults.test.js
+++ b/tests/defaults.test.js
@@ -51,8 +51,8 @@ beforeAll(
const { stdout } = await npmRunBuild({ directory: PROJECT_PATH })
BUILD_OUTPUT = stdout
},
- // time out after 60 seconds
- 60 * 1000
+ // time out after 180 seconds
+ 180 * 1000
)
describe('Next', () => {
@@ -100,6 +100,8 @@ describe('SSG Pages with getStaticProps', () => {
expect(existsSync(join(OUTPUT_PATH, "getStaticProps", "static.html"))).toBe(true)
expect(existsSync(join(OUTPUT_PATH, "getStaticProps", "1.html"))).toBe(true)
expect(existsSync(join(OUTPUT_PATH, "getStaticProps", "2.html"))).toBe(true)
+ expect(existsSync(join(OUTPUT_PATH, "getStaticProps", "withFallback", "3.html"))).toBe(true)
+ expect(existsSync(join(OUTPUT_PATH, "getStaticProps", "withFallback", "4.html"))).toBe(true)
})
test('creates data .json file in /_next/data/ directory', () => {
@@ -111,6 +113,13 @@ describe('SSG Pages with getStaticProps', () => {
expect(existsSync(join(dataDir, "getStaticProps", "static.json"))).toBe(true)
expect(existsSync(join(dataDir, "getStaticProps", "1.json"))).toBe(true)
expect(existsSync(join(dataDir, "getStaticProps", "2.json"))).toBe(true)
+ expect(existsSync(join(dataDir, "getStaticProps", "withFallback", "3.json"))).toBe(true)
+ expect(existsSync(join(dataDir, "getStaticProps", "withFallback", "4.json"))).toBe(true)
+ })
+
+ test('creates Netlify Function for pages with fallback', () => {
+ const functionPath = "next_getStaticProps_withFallback_id/next_getStaticProps_withFallback_id.js"
+ expect(existsSync(join(PROJECT_PATH, "out_functions", functionPath))).toBe(true)
})
})
@@ -156,7 +165,10 @@ describe('Routing',() => {
test('creates Netlify redirects', async () => {
// Read _redirects file
const contents = readFileSync(join(PROJECT_PATH, "out_publish", "_redirects"))
- const redirects = contents.toString()
+ let redirects = contents.toString()
+
+ // Remove build-specific data path
+ redirects = redirects.replace(/\/_next\/data\/[^\/]+\//, "/_next/data/$path$/")
// Check that redirects match
expect(redirects).toMatchSnapshot()
diff --git a/tests/fixtures/pages/getStaticProps/withFallback/[id].js b/tests/fixtures/pages/getStaticProps/withFallback/[id].js
new file mode 100644
index 0000000..fa542dd
--- /dev/null
+++ b/tests/fixtures/pages/getStaticProps/withFallback/[id].js
@@ -0,0 +1,62 @@
+import { useRouter } from 'next/router'
+import Link from 'next/link'
+
+const Show = ({ show }) => {
+ const router = useRouter()
+
+ // This is never shown on Netlify. We just need it for NextJS to be happy,
+ // because NextJS will render a fallback HTML page.
+ if (router.isFallback) {
+ return Loading...
+ }
+
+ return (
+
+
+ This page uses getStaticProps() to pre-fetch a TV show.
+
+
+
+
+
Show #{show.id}
+
+ {show.name}
+
+
+
+
+
+
Go back home
+
+
+ )
+}
+
+export async function getStaticPaths() {
+ // Set the paths we want to pre-render
+ const paths = [
+ { params: { id: '3' } },
+ { params: { id: '4' } }
+ ]
+
+ // We'll pre-render these paths at build time.
+ // { fallback: true } means other routes will be rendered at runtime.
+ return { paths, fallback: true }
+}
+
+
+export async function getStaticProps({ params }) {
+ // The ID to render
+ const { id } = params
+
+ const res = await fetch(`https://api.tvmaze.com/shows/${id}`);
+ const data = await res.json();
+
+ return {
+ props: {
+ show: data
+ }
+ }
+}
+
+export default Show
diff --git a/tests/preRenderedIndexPages.test.js b/tests/preRenderedIndexPages.test.js
index c03f203..5e54e72 100644
--- a/tests/preRenderedIndexPages.test.js
+++ b/tests/preRenderedIndexPages.test.js
@@ -47,8 +47,8 @@ beforeAll(
const { stdout } = await npmRunBuild({ directory: PROJECT_PATH })
BUILD_OUTPUT = stdout
},
- // time out after 60 seconds
- 60 * 1000
+ // time out after 180 seconds
+ 180 * 1000
)
describe('Next', () => {