Note
This site was initially deployed to Vercel, but my free tier was canceled because of too much traffic.
Wanting to migrate it to Github Pages, it initially did not work out because Github Pages is only possible for static pages.
Even though Nextjs has the ability to export static pages (with output: 'export' in nextConfig), this requires limiting the code to the featureset that supports static site generation:
When using output: 'export' Nextjs will throw the runtime Error Page "/[network]/page" is missing exported function "generateStaticParams()", which is required with "output: export" config because of the use of the dynamic route [network]:
- We could either switch to
output: 'standalone', but this is not compatible with Github Pages, meaning we should deploy to another hosting provider like Deno Deploy, Render or Railway. (See the Nextjs Github for possible deploy templates.) I attempted using Deno Deploy, but this didn't work out because it is a total mess ... - Or we could try to implement generateStaticParams() to statically generate the dynamic routes at build time. However, this is not possible due to the fact that there are dynamic routes that aren't known at build time, e.g.
mainnet/address/[hash]. - As a third option, we could implement generateStaticParams() for the
[network]route, and switch to URL params for the other dynamic routes.
NO!!! GitHub Pages is a static host and standalone is not a static export! Github Pages explicitly requires output: 'export'!
There are 3 ways to deploy/host Next.js:
- Node.js Server
- Docker Image
- Static HTML Export
So output: 'standalone' requires one of the first 2 options, which support all Next.js features. As the name implies, a static export does not support Next.js features that require a server.
I will now document how to deploy to Github Pages with Github Actions, using the third option above, with output: 'export', generateStaticParams() for the [network] route, and switch the other dynamic routes to URL parameters.
Enable output: 'export' in next.config.mjs, and add the project as basePath:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
basePath: '/ethblox',
};
export default nextConfig;After running next build, Next.js will create an out folder with the HTML/CSS/JS assets for your application.
Remove redirects() from next.config.mjs:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
// async redirects() {
// return [
// {
// source: '/',
// destination: '/mainnet',
// permanent: true,
// },
// ]
// },
};
export default nextConfig;This means the homepage will no longer redirect to /mainnet, so we'll need to find another solution for this later.
3. Implement generateStaticParams() for the [network] route
We add a generateStaticParams() to all [network] routes (i.e. all page.tsx files in the [network] folder):
// Return a list of `params` to populate the [network] dynamic segment:
export async function generateStaticParams() {
return [{ network: 'mainnet' }, { network: 'sepolia' }];
}- We remove the remaining dynamic URL params from the routes:
git mv src/app/[network]/address/[hash]/page.tsx src/app/[network]/address/page.tsx git mv src/app/[network]/block/[number]/page.tsx src/app/[network]/block/page.tsx git mv src/app/[network]/transaction/[hash]/page.tsx src/app/[network]/transaction/page.tsx
- We search and replace their respective
hrefinstances:- Replace
href={`/${props.network}/address/byhref={`/${props.network}/address?hash=. - Replace
href={`/${props.network}/block/byhref={`/${props.network}/block?number=. - Replace
href={`/${props.network}/transaction/byhref={`/${props.network}/transaction?hash=.
- Replace
- We update their respective
pages.tsxfiles withuseSearchParams()to read the URL's query string.useSearchParams()requiresuse client(regularsearchParamscan be used in server components, but this is not compatible withoutput: exportfor static sites). SincegenerateStaticParams()requires a Server Component, thepage.tsxfiles need to remain Server Components, anduseSearchParams()needs to be moved to their children components which can be made Client components. E.g. forsrc/app/[network]/address/page.tsx:And itsimport { createAlchemy } from '@/lib/utilities'; import AddressPage from '@/components/content/address-page'; import NotFoundPage from '@/components/content/error-page/not-found-page'; export async function generateStaticParams() { return [{ network: 'mainnet' }, { network: 'sepolia' }]; } // Remove `hash: string` from params below: export default async function Page({params} : {params: Promise<{network: string}>}) /* <-- */ { const network = (await params).network; if (network !== 'mainnet' && network !== 'sepolia') { return <NotFoundPage reason={ `"${network}" is not a valid Ethereum network.`} />; } return ( <AddressPage // hash={hash!} // <-- Remove hash! network={network} alchemy={createAlchemy(network)} /> ); }
AddressPagesrc/components/content/address-page/index.tsx:Also, since'use client'; // <-- Required for `useSearchParams` import { Utils, Alchemy } from 'alchemy-sdk'; import Tokens from './tokens'; import Transactions from './transactions'; import EthBalance from './eth-balance'; import { useSearchParams } from 'next/navigation'; // <-- type Props = { // hash: string, // <-- Remove hash! network: string, alchemy: Alchemy } export default async function AddressPage(props: Props) { let ethBalance, badAddress; let success = false; const searchParams = useSearchParams(); // <-- const hash = searchParams.get('hash')!; // <-- while (!success) { try { ethBalance = Utils.formatEther(await props.alchemy.core.getBalance(hash, 'latest')); // <-- Replace `props.hash` with `hash`! badAddress = false; success = true; } catch(err) { ... // Replace other instances of `props.hash` with `hash` also!!!
AddressPagenow becomes a client component because ofuseSearchParams(), this component can no longer be async (not allowed for Client Components). This means the async data fetching also has to be moved to auseEffect()hook, and theuseState()hook will have to be used to persist the data outside ofuseEffect.
Etc. etc. - To fix the 404 on the root domain (since we removed the redirect to
/mainnet), we switch from the dynamic[network]URL param to an optional catch-all route param[[...slug]]. But this requires quite a few changes since now all the routing for all routes has to be done in[[...slug]]/page.tsx, andgenerateStaticParams()will have to generate params for all these routes (See commit 755ddeb3f7ce2a1af2b32ae19b0c209bf70f7119). - Now there are still quite a few errors remaining because
useEffect()runs after the first render! This means the data it fetches is undefined on first render. This means we need to provide a fallback for undefined values, e.g.:<span>{blockReward ? blockReward : loadingIndicator}</span>
- Additionally, passing the
Alchemyobject from server to client components is not possible, so it needs to be recreated in the client component. It is still possible to cache it inlib/utilities.ts, and only conditionally create it if it doesn't exist yet.
- After checking out Key security Best practices and Using JWTs, it is not clear to me how one would go about updating the JWTs, since they are supposed to be short-lived. In the example, their expiration time is even 10 minutes! How to keep the service functioning if JWTs are expiring that fast?
- Is it possible to use other API providers than Alchemy itself, like it is with ethers.js? (E.g. Infura, Etherscan, Cloudfare, etc.)
-
Persistent bug:
Failed to generate cache key for https://eth-mainnet.g.alchemy.com/v2/[ALCHEMY_API_KEY]By grepping inside the
nextnode_module, we find a dozen possible locations for this message:node_modules\next\dist\compiled\next-server\app-page-experimental.runtime.dev.js.map node_modules\next\dist\compiled\next-server\app-page-experimental.runtime.prod.js.map node_modules\next\dist\compiled\next-server\app-page-turbo-experimental.runtime.prod.js.map node_modules\next\dist\compiled\next-server\app-page-turbo.runtime.prod.js.map node_modules\next\dist\compiled\next-server\app-page.runtime.dev.js.map node_modules\next\dist\compiled\next-server\app-page.runtime.prod.js.map node_modules\next\dist\compiled\next-server\app-route-experimental.runtime.dev.js node_modules\next\dist\compiled\next-server\app-route-experimental.runtime.dev.js.map node_modules\next\dist\compiled\next-server\app-route-experimental.runtime.prod.js node_modules\next\dist\compiled\next-server\app-route-experimental.runtime.prod.js.map node_modules\next\dist\compiled\next-server\app-route-turbo-experimental.runtime.prod.js node_modules\next\dist\compiled\next-server\app-route-turbo-experimental.runtime.prod.js.map node_modules\next\dist\compiled\next-server\app-route-turbo.runtime.prod.js node_modules\next\dist\compiled\next-server\app-route-turbo.runtime.prod.js.map node_modules\next\dist\compiled\next-server\app-route.runtime.dev.js node_modules\next\dist\compiled\next-server\app-route.runtime.dev.js.map node_modules\next\dist\compiled\next-server\app-route.runtime.prod.js node_modules\next\dist\compiled\next-server\app-route.runtime.prod.js.map node_modules\next\dist\esm\server\lib\patch-fetch.js node_modules\next\dist\server\lib\patch-fetch.jsWe can tell from these paths that the file we'll likely need is
patch-fetch.js, since it's located directly in the server, without interveningcompiledoresm(ECMAScript modules) folders. The location of the error looks like this:const isCacheableRevalidate = typeof revalidate === "number" && revalidate > 0 || revalidate === false; let cacheKey; if (staticGenerationStore.incrementalCache && isCacheableRevalidate) { try { cacheKey = await staticGenerationStore.incrementalCache.fetchCacheKey(fetchUrl, isRequestInput ? input : init); } catch (err) { console.error(`Failed to generate cache key for`, input); // We put in an extra error log here, to verify this is the file: console.error(err); } }
After restarting the dev server with
npm run dev, we get the following error log:Failed to generate cache key for https://eth-mainnet.g.alchemy.com/v2/[ALCHEMY_API_KEY] TypeError: formData.getAll is not a function at IncrementalCache.fetchCacheKey (A:\Dev\blockchain\alchemy-ethereum-developer-bootcamp\week-3\blockexplorer_nextjs\node_modules\next\dist\server\lib\incremental-cache\index.js:233:45) at eval (webpack-internal:///(rsc)/./node_modules/next/dist/server/lib/patch-fetch.js:386:77) at eval (webpack-internal:///(rsc)/./node_modules/next/dist/server/lib/trace/tracer.js:134:36) at NoopContextManager.with (webpack-internal:///(rsc)/./node_modules/next/dist/compiled/@opentelemetry/api/index.js:1:7062) at ContextAPI.with (webpack-internal:///(rsc)/./node_modules/next/dist/compiled/@opentelemetry/api/index.js:1:518) at NoopTracer.startActiveSpan (webpack-internal:///(rsc)/./node_modules/next/dist/compiled/@opentelemetry/api/index.js:1:18093) at ProxyTracer.startActiveSpan (webpack-internal:///(rsc)/./node_modules/next/dist/compiled/@opentelemetry/api/index.js:1:18854) at eval (webpack-internal:///(rsc)/./node_modules/next/dist/server/lib/trace/tracer.js:116:103) at NoopContextManager.with (webpack-internal:///(rsc)/./node_modules/next/dist/compiled/@opentelemetry/api/index.js:1:7062) at ContextAPI.with (webpack-internal:///(rsc)/./node_modules/next/dist/compiled/@opentelemetry/api/index.js:1:518) at NextTracerImpl.trace (webpack-internal:///(rsc)/./node_modules/next/dist/server/lib/trace/tracer.js:116:28) at patched (webpack-internal:///(rsc)/./node_modules/next/dist/server/lib/patch-fetch.js:233:41) at eval (webpack-internal:///(rsc)/./node_modules/@ethersproject/web/lib.esm/geturl.js:53:32) at Generator.next (<anonymous>) at eval (webpack-internal:///(rsc)/./node_modules/@ethersproject/web/lib.esm/geturl.js:13:71) at new Promise (<anonymous>) at __awaiter (webpack-internal:///(rsc)/./node_modules/@ethersproject/web/lib.esm/geturl.js:9:12) at getUrl (webpack-internal:///(rsc)/./node_modules/@ethersproject/web/lib.esm/geturl.js:18:12) at eval (webpack-internal:///(rsc)/./node_modules/@ethersproject/web/lib.esm/index.js:194:85) at Generator.next (<anonymous>) at eval (webpack-internal:///(rsc)/./node_modules/@ethersproject/web/lib.esm/index.js:21:71) at new Promise (<anonymous>) at __awaiter (webpack-internal:///(rsc)/./node_modules/@ethersproject/web/lib.esm/index.js:17:12) at eval (webpack-internal:///(rsc)/./node_modules/@ethersproject/web/lib.esm/index.js:190:16) at _fetchData (webpack-internal:///(rsc)/./node_modules/@ethersproject/web/lib.esm/index.js:294:7) at Module.fetchJson (webpack-internal:///(rsc)/./node_modules/@ethersproject/web/lib.esm/index.js:336:12) at AlchemyProvider._send (webpack-internal:///(rsc)/./node_modules/alchemy-sdk/dist/cjs/alchemy-provider-8762fa7e.js:303:28) at AlchemyProvider.send (webpack-internal:///(rsc)/./node_modules/alchemy-sdk/dist/cjs/alchemy-provider-8762fa7e.js:267:21) at AlchemyProvider.eval (webpack-internal:///(rsc)/./node_modules/@ethersproject/providers/lib.esm/json-rpc-provider.js:588:35) at Generator.next (<anonymous>) at eval (webpack-internal:///(rsc)/./node_modules/@ethersproject/providers/lib.esm/json-rpc-provider.js:24:71) at new Promise (<anonymous>) at __awaiter (webpack-internal:///(rsc)/./node_modules/@ethersproject/providers/lib.esm/json-rpc-provider.js:20:12) at AlchemyProvider.perform (webpack-internal:///(rsc)/./node_modules/@ethersproject/providers/lib.esm/json-rpc-provider.js:565:16) at AlchemyProvider.eval (webpack-internal:///(rsc)/./node_modules/@ethersproject/providers/lib.esm/base-provider.js:1282:39) at Generator.next (<anonymous>) at fulfilled (webpack-internal:///(rsc)/./node_modules/@ethersproject/providers/lib.esm/base-provider.js:28:58) at runNextTicks (node:internal/process/task_queues:65:5) at listOnTimeout (node:internal/timers:555:9) at process.processTimers (node:internal/timers:529:7)which means we are at least in the right file!
This bug seems to be caused by a cache for hot reloads in local development.
From Next 15 onwards, you can disable this cache like this in
next.config.js:/** @type {import('next').NextConfig} */ const nextConfig = { experimental: { serverComponentsHmrCache: false, // defaults to true }, } module.exports = nextConfig
See the Nextjs serverComponentsHmrCache docs for more info.
- Commit
ea4e75d93567fc1b4e2494c6d5a6b6254fe39dcehas a really difficult bug to track down: it has a spacing at the bottom, right above the footer that I couldn't get rid off. It is caused by the negative positioning of the flex container that makes the Stats component overlap with the NodeBanner (line 50 insrc/components/content/home-page/index.tsx). Changingtop: -6rem;tomargin-top: -6rem;fixes this, or in Tailwind speak-top-[6rem]to-mt-[6rem].