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

Commit 81026fb

Browse files
committed
Add support for dynamically routed SSG pages with fallback
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
1 parent 6a6c7ed commit 81026fb

File tree

11 files changed

+315
-55
lines changed

11 files changed

+315
-55
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { useRouter } from 'next/router'
2+
import Link from 'next/link'
3+
4+
const Show = ({ show }) => {
5+
const router = useRouter()
6+
7+
// This is never shown on Netlify. We just need it for NextJS to be happy,
8+
// because NextJS will render a fallback HTML page.
9+
if (router.isFallback) {
10+
return <div>Loading...</div>
11+
}
12+
13+
return (
14+
<div>
15+
<p>
16+
This page uses getStaticProps() to pre-fetch a TV show.
17+
</p>
18+
19+
<hr/>
20+
21+
<h1>Show #{show.id}</h1>
22+
<p>
23+
{show.name}
24+
</p>
25+
26+
<hr/>
27+
28+
<Link href="/">
29+
<a>Go back home</a>
30+
</Link>
31+
</div>
32+
)
33+
}
34+
35+
export async function getStaticPaths() {
36+
// Set the paths we want to pre-render
37+
const paths = [
38+
{ params: { id: '3' } },
39+
{ params: { id: '4' } }
40+
]
41+
42+
// We'll pre-render these paths at build time.
43+
// { fallback: true } means other routes will be rendered at runtime.
44+
return { paths, fallback: true }
45+
}
46+
47+
48+
export async function getStaticProps({ params }) {
49+
// The ID to render
50+
const { id } = params
51+
52+
const res = await fetch(`https://api.tvmaze.com/shows/${id}`);
53+
const data = await res.json();
54+
55+
return {
56+
props: {
57+
show: data
58+
}
59+
}
60+
}
61+
62+
export default Show

cypress/fixtures/pages/index.js

+17-2
Original file line numberDiff line numberDiff line change
@@ -101,15 +101,30 @@ const Index = ({ shows }) => (
101101
</Link>
102102
</li>
103103
<li>
104-
<Link href="/getStaticProps/1">
104+
<Link href="/getStaticProps/[id]" as="/getStaticProps/1">
105105
<a>getStaticProps/1 (dynamic route)</a>
106106
</Link>
107107
</li>
108108
<li>
109-
<Link href="/getStaticProps/2">
109+
<Link href="/getStaticProps/[id]" as="/getStaticProps/2">
110110
<a>getStaticProps/2 (dynamic route)</a>
111111
</Link>
112112
</li>
113+
<li>
114+
<Link href="/getStaticProps/withFallback/[id]" as="/getStaticProps/withFallback/3">
115+
<a>getStaticProps/withFallback/3 (dynamic route)</a>
116+
</Link>
117+
</li>
118+
<li>
119+
<Link href="/getStaticProps/withFallback/[id]" as="/getStaticProps/withFallback/4">
120+
<a>getStaticProps/withFallback/4 (dynamic route)</a>
121+
</Link>
122+
</li>
123+
<li>
124+
<Link href="/getStaticProps/withFallback/[id]" as="/getStaticProps/withFallback/75">
125+
<a>getStaticProps/withFallback/75 (dynamic route, not pre-rendered)</a>
126+
</Link>
127+
</li>
113128
</ul>
114129

115130
<h1>5. Static Pages Stay Static</h1>

cypress/integration/default_spec.js

+86-32
Original file line numberDiff line numberDiff line change
@@ -138,53 +138,107 @@ describe('getStaticProps', () => {
138138
// Navigate to page and test that no reload is performed
139139
// See: https://glebbahmutov.com/blog/detect-page-reload/
140140
cy.contains('getStaticProps/static').click()
141-
cy.window().should('have.property', 'noReload', true)
142-
143141
cy.get('h1').should('contain', 'Show #71')
144142
cy.get('p').should('contain', 'Dancing with the Stars')
143+
cy.window().should('have.property', 'noReload', true)
145144
})
146145
})
147146

148-
context('with dynamic route and no fallback', () => {
149-
it('loads shows 1 and 2', () => {
150-
cy.visit('/getStaticProps/1')
151-
cy.get('h1').should('contain', 'Show #1')
152-
cy.get('p').should('contain', 'Under the Dome')
147+
context('with dynamic route', () => {
148+
context('without fallback', () => {
149+
it('loads shows 1 and 2', () => {
150+
cy.visit('/getStaticProps/1')
151+
cy.get('h1').should('contain', 'Show #1')
152+
cy.get('p').should('contain', 'Under the Dome')
153+
154+
cy.visit('/getStaticProps/2')
155+
cy.get('h1').should('contain', 'Show #2')
156+
cy.get('p').should('contain', 'Person of Interest')
157+
})
153158

154-
cy.visit('/getStaticProps/2')
155-
cy.get('h1').should('contain', 'Show #2')
156-
cy.get('p').should('contain', 'Person of Interest')
157-
})
159+
it('loads page props from data .json file when navigating to it', () => {
160+
cy.visit('/')
161+
cy.window().then(w => w.noReload = true)
158162

159-
it('loads page props from data .json file when navigating to it', () => {
160-
cy.visit('/')
161-
cy.window().then(w => w.noReload = true)
163+
// Navigate to page and test that no reload is performed
164+
// See: https://glebbahmutov.com/blog/detect-page-reload/
165+
cy.contains('getStaticProps/1').click()
162166

163-
// Navigate to page and test that no reload is performed
164-
// See: https://glebbahmutov.com/blog/detect-page-reload/
165-
cy.contains('getStaticProps/1').click()
166-
cy.window().should('have.property', 'noReload', true)
167+
cy.get('h1').should('contain', 'Show #1')
168+
cy.get('p').should('contain', 'Under the Dome')
167169

168-
cy.get('h1').should('contain', 'Show #1')
169-
cy.get('p').should('contain', 'Under the Dome')
170+
cy.contains('Go back home').click()
171+
cy.contains('getStaticProps/2').click()
170172

171-
cy.contains('Go back home').click()
172-
cy.contains('getStaticProps/2').click()
173+
cy.get('h1').should('contain', 'Show #2')
174+
cy.get('p').should('contain', 'Person of Interest')
173175

174-
cy.get('h1').should('contain', 'Show #2')
175-
cy.get('p').should('contain', 'Person of Interest')
176+
cy.window().should('have.property', 'noReload', true)
177+
})
178+
179+
it('returns 404 when trying to access non-defined path', () => {
180+
cy.request({
181+
url: '/getStaticProps/3',
182+
failOnStatusCode: false
183+
}).then(response => {
184+
expect(response.status).to.eq(404)
185+
cy.state('document').write(response.body)
186+
})
187+
188+
cy.get('h2').should('contain', 'This page could not be found.')
189+
})
176190
})
177191

178-
it('returns 404 when trying to access non-defined path', () => {
179-
cy.request({
180-
url: '/getStaticProps/3',
181-
failOnStatusCode: false
182-
}).then(response => {
183-
expect(response.status).to.eq(404)
184-
cy.state('document').write(response.body)
192+
context('with fallback', () => {
193+
it('loads pre-rendered TV shows 3 and 4', () => {
194+
cy.visit('/getStaticProps/withFallback/3')
195+
cy.get('h1').should('contain', 'Show #3')
196+
cy.get('p').should('contain', 'Bitten')
197+
198+
cy.visit('/getStaticProps/withFallback/4')
199+
cy.get('h1').should('contain', 'Show #4')
200+
cy.get('p').should('contain', 'Arrow')
201+
})
202+
203+
it('loads non-pre-rendered TV show', () => {
204+
cy.visit('/getStaticProps/withFallback/75')
205+
206+
cy.get('h1').should('contain', 'Show #75')
207+
cy.get('p').should('contain', 'The Mindy Project')
185208
})
186209

187-
cy.get('h2').should('contain', 'This page could not be found.')
210+
it('loads non-pre-rendered TV shows when SSR-ing', () => {
211+
cy.ssr('/getStaticProps/withFallback/75')
212+
213+
cy.get('h1').should('contain', 'Show #75')
214+
cy.get('p').should('contain', 'The Mindy Project')
215+
})
216+
217+
it('loads page props from data .json file when navigating to it', () => {
218+
cy.visit('/')
219+
cy.window().then(w => w.noReload = true)
220+
221+
// Navigate to page and test that no reload is performed
222+
// See: https://glebbahmutov.com/blog/detect-page-reload/
223+
cy.contains('getStaticProps/withFallback/3').click()
224+
225+
cy.get('h1').should('contain', 'Show #3')
226+
cy.get('p').should('contain', 'Bitten')
227+
228+
cy.contains('Go back home').click()
229+
cy.contains('getStaticProps/withFallback/4').click()
230+
231+
cy.get('h1').should('contain', 'Show #4')
232+
cy.get('p').should('contain', 'Arrow')
233+
234+
cy.contains('Go back home').click()
235+
cy.contains('getStaticProps/withFallback/75').click()
236+
237+
cy.get('h1').should('contain', 'Show #75')
238+
cy.get('p').should('contain', 'The Mindy Project')
239+
240+
cy.window().should('have.property', 'noReload', true)
241+
})
188242
})
189243
})
190244
})

lib/allNextJsPages.js

+16-3
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const getAllPages = () => {
1212
)
1313

1414
// Read prerender manifest that tells us which SSG pages exist
15-
const { routes: ssgPages, dynamicRoutes: dynamicSsgPages } = readJSONSync(
15+
const { routes: staticSsgPages, dynamicRoutes: dynamicSsgPages } = readJSONSync(
1616
join(NEXT_DIST_DIR, "prerender-manifest.json")
1717
)
1818

@@ -23,7 +23,7 @@ const getAllPages = () => {
2323
return
2424

2525
// Skip page if it is actually an SSG page
26-
if(route in ssgPages || route in dynamicSsgPages)
26+
if(route in staticSsgPages || route in dynamicSsgPages)
2727
return
2828

2929
// Check if we already have a page pointing to this file
@@ -40,9 +40,18 @@ const getAllPages = () => {
4040
})
4141

4242
// Parse SSG pages
43-
Object.entries(ssgPages).forEach(([ route, { dataRoute }]) => {
43+
Object.entries(staticSsgPages).forEach(([ route, { dataRoute }]) => {
4444
pages.push(new Page({ route, type: "ssg", dataRoute }))
4545
})
46+
Object.entries(dynamicSsgPages).forEach(([ route, { dataRoute, fallback }]) => {
47+
// Ignore pages without fallback, these are already handled by the
48+
// static SSG page block above
49+
if(fallback === false)
50+
return
51+
52+
const filePath = join("pages", `${route}.js`)
53+
pages.push(new Page({ route, filePath, type: "ssg-fallback", alternativeRoutes: [dataRoute] }))
54+
})
4655

4756
return pages
4857
}
@@ -66,6 +75,10 @@ class Page {
6675
return this.type === "ssg"
6776
}
6877

78+
isSsgFallback() {
79+
return this.type === "ssg-fallback"
80+
}
81+
6982
// Return route and alternative routes as array
7083
get routesAsArray() {
7184
return [this.route, ...this.alternativeRoutes]

lib/setupRedirects.js

+5-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const setupRedirects = () => {
1919

2020
// Sort dynamic pages by route: More-specific routes precede less-specific
2121
// routes
22-
const dynamicRoutes = dynamicPages.map(page => page.routesAsArray).flat()
22+
const dynamicRoutes = dynamicPages.map(page => page.route)
2323
const sortedDynamicRoutes = getSortedRoutes(dynamicRoutes)
2424
const sortedDynamicPages = dynamicPages.sort((a, b) => (
2525
sortedDynamicRoutes.indexOf(a.route) - sortedDynamicRoutes.indexOf(b.route)
@@ -44,6 +44,10 @@ const setupRedirects = () => {
4444
else if (page.isSsg()) {
4545
to = `${page.route}.html`
4646
}
47+
// SSG fallback pages (for non pre-rendered paths)
48+
else if (page.isSsgFallback()) {
49+
to = `/.netlify/functions/${getNetlifyFunctionName(page.filePath)}`
50+
}
4751
// Pre-rendered HTML pages
4852
else if (page.isHtml()) {
4953
to = `/${path.relative("pages", page.filePath)}`

lib/setupSsgPages.js

+44-10
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
1-
const path = require('path')
2-
const { join } = path
3-
const { copySync, existsSync, readJSONSync } = require('fs-extra')
4-
const { NEXT_DIST_DIR, NETLIFY_PUBLISH_PATH } = require('./config')
1+
const path = require('path')
2+
const { join } = path
3+
const { copySync, existsSync } = require('fs-extra')
4+
const { NEXT_DIST_DIR, NETLIFY_PUBLISH_PATH,
5+
NETLIFY_FUNCTIONS_PATH,
6+
FUNCTION_TEMPLATE_PATH } = require('./config')
7+
const allNextJsPages = require('./allNextJsPages')
8+
const getNetlifyFunctionName = require('./getNetlifyFunctionName')
59

610
// Identify all pages that require server-side rendering and create a separate
711
// Netlify Function for every page.
812
const setupSsgPages = () => {
913
console.log(`\x1b[1m🔥 Setting up SSG pages\x1b[22m`)
1014

11-
// Read prerender manifest that tells us which SSG pages exist
12-
const { routes } = readJSONSync(
13-
join(NEXT_DIST_DIR, "prerender-manifest.json")
14-
)
15+
// Get SSG pages
16+
const ssgPages = allNextJsPages.filter(page => page.isSsg())
1517

1618
// Copy pre-rendered SSG pages to Netlify publish folder
1719
console.log(" ", "1. Copying pre-rendered SSG pages to", NETLIFY_PUBLISH_PATH)
1820

19-
Object.keys(routes).forEach(route => {
21+
ssgPages.forEach(({ route }) => {
2022
const filePath = join("pages", `${route}.html`)
2123
console.log(" ", " ", filePath)
2224

@@ -34,7 +36,7 @@ const setupSsgPages = () => {
3436
const nextDataFolder = join(NETLIFY_PUBLISH_PATH, "_next", "data/")
3537
console.log(" ", "2. Copying SSG page data to", nextDataFolder)
3638

37-
Object.entries(routes).forEach(([route, { dataRoute }]) => {
39+
ssgPages.forEach(({ route, dataRoute }) => {
3840
const dataPath = join("pages", `${route}.json`)
3941
console.log(" ", " ", dataPath)
4042

@@ -47,6 +49,38 @@ const setupSsgPages = () => {
4749
}
4850
)
4951
})
52+
53+
// Set up Netlify Functions to handle fallbacks for SSG pages
54+
const ssgFallbackPages = allNextJsPages.filter(page => page.isSsgFallback())
55+
console.log(" ", "3. Setting up Netlify Functions for SSG pages with fallback: true")
56+
57+
ssgFallbackPages.forEach(({ filePath }) => {
58+
console.log(" ", " ", filePath)
59+
60+
// Set function name based on file path
61+
const functionName = getNetlifyFunctionName(filePath)
62+
const functionDirectory = join(NETLIFY_FUNCTIONS_PATH, functionName)
63+
64+
// Copy function template
65+
copySync(
66+
FUNCTION_TEMPLATE_PATH,
67+
join(functionDirectory, `${functionName}.js`),
68+
{
69+
overwrite: false,
70+
errorOnExist: true
71+
}
72+
)
73+
74+
// Copy page
75+
copySync(
76+
join(NEXT_DIST_DIR, "serverless", filePath),
77+
join(functionDirectory, "nextJsPage.js"),
78+
{
79+
overwrite: false,
80+
errorOnExist: true
81+
}
82+
)
83+
})
5084
}
5185

5286
module.exports = setupSsgPages

0 commit comments

Comments
 (0)