Skip to content
This repository has been archived by the owner on May 10, 2021. It is now read-only.

Commit

Permalink
Add support for dynamically routed SSG pages with fallback
Browse files Browse the repository at this point in the history
Copy pre-rendered, dynamically routed pages with SSG to Netlify publish
directory and copy SSG page JSON data to _next/data/ directory.

Create a Netlify Function to handle the paths not defined in the page's
getStaticPaths. When requesting a page that has not been pre-rendered,
it will be SSRed by this function. The function also returns the page
data for paths that have not been pre-defined.

See: #7
  • Loading branch information
FinnWoelm committed Jun 1, 2020
1 parent 6a6c7ed commit 81026fb
Show file tree
Hide file tree
Showing 11 changed files with 315 additions and 55 deletions.
62 changes: 62 additions & 0 deletions cypress/fixtures/pages/getStaticProps/withFallback/[id].js
Original file line number Diff line number Diff line change
@@ -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 <div>Loading...</div>
}

return (
<div>
<p>
This page uses getStaticProps() to pre-fetch a TV show.
</p>

<hr/>

<h1>Show #{show.id}</h1>
<p>
{show.name}
</p>

<hr/>

<Link href="/">
<a>Go back home</a>
</Link>
</div>
)
}

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
19 changes: 17 additions & 2 deletions cypress/fixtures/pages/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,15 +101,30 @@ const Index = ({ shows }) => (
</Link>
</li>
<li>
<Link href="/getStaticProps/1">
<Link href="/getStaticProps/[id]" as="/getStaticProps/1">
<a>getStaticProps/1 (dynamic route)</a>
</Link>
</li>
<li>
<Link href="/getStaticProps/2">
<Link href="/getStaticProps/[id]" as="/getStaticProps/2">
<a>getStaticProps/2 (dynamic route)</a>
</Link>
</li>
<li>
<Link href="/getStaticProps/withFallback/[id]" as="/getStaticProps/withFallback/3">
<a>getStaticProps/withFallback/3 (dynamic route)</a>
</Link>
</li>
<li>
<Link href="/getStaticProps/withFallback/[id]" as="/getStaticProps/withFallback/4">
<a>getStaticProps/withFallback/4 (dynamic route)</a>
</Link>
</li>
<li>
<Link href="/getStaticProps/withFallback/[id]" as="/getStaticProps/withFallback/75">
<a>getStaticProps/withFallback/75 (dynamic route, not pre-rendered)</a>
</Link>
</li>
</ul>

<h1>5. Static Pages Stay Static</h1>
Expand Down
118 changes: 86 additions & 32 deletions cypress/integration/default_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
})
})
Expand Down
19 changes: 16 additions & 3 deletions lib/allNextJsPages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")
)

Expand All @@ -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
Expand All @@ -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
}
Expand All @@ -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]
Expand Down
6 changes: 5 additions & 1 deletion lib/setupRedirects.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)}`
Expand Down
54 changes: 44 additions & 10 deletions lib/setupSsgPages.js
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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)

Expand All @@ -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
Loading

0 comments on commit 81026fb

Please sign in to comment.