This is the Alchemy Ethereum Developer Bootcamp Week 3 project, which I've been using to learn Next.js, and how its client/server components impact static site generation. Typescript and TailwindCSS were a first for me too, so overall it's been a very instructive experience.
The starter code and assignment are available here.
I migrated it to Next.js, bootstrapped with create-next-app.
Most prominent technologies used:
Important
To make the Alchemy API calls actually work, you will need an Alchemy API key, which you can get freely by creating an Alchemy account. Put the key into an .env file in the root of the project, without quotes:
NEXT_PUBLIC_ALCHEMY_API_KEY=xyzThe NEXT_PUBLIC_ prefix is required to make it accessible to the browser.
- Clone this repo, and cd into it in your terminal:
git clone https://github.com/davidde/ethblox; cd ethblox
- Install the project's packages:
npm install
- Then start the development server:
This will start the Next.js development server on port 3005 (set in
npm run dev
package.json > scripts > dev), so open http://localhost:3005 in the browser to see the result.
Warning
The development server will not fully reflect the production state of the application, since we're using output: export for full static site generation for Github Pages. The hot reload is still useful for quick iteration and feedback, but it is still necessary to frequently check the actual static site output with serve out after building with npm run build (install serve with npm install -g serve). It is also necessary to set the LOCAL_BUILD environment variable before building, otherwise the incorrect basePath will be applied in nextConfig, and all asset files (including CSS!) will 404.
For example, in PowerShell:
$env:LOCAL_BUILD='true'; npm run build; serve outThe cross-env version of the above command is specified for npm run start, so you can just use npm run start to start the production application, regardless of platform. Note that this obviously isn't "live", and will need to be repeated after code changes.
- Start the "production" static site:
# Requires the `serve` package: # npm install -g serve npm run start
This project was started as a Next.js application with server-side rendering hosted on Vercel, and later converted to a static site with output: export (see some notes on the process). I'm listing some important nuances here for reference:
- Dynamic routes like
[network]/address/[hash]/0x...are not supported on static sites (since it needs to generate an actual.htmlfile for every page):- For
[network]we can implement generateStaticParams() to statically generate the dynamic route[network]at build time, since we only needmainnetandsepolianetwork parameters. - However, for
[hash]this is not possible, since it isn't known at build time, and we can't generate pages for all possible hashes. As a consequence, this dynamic route needs to be switched to use URL parameters likemainnet/address?hash=0x.... - To make the above URL params work, we need
useSearchParams()to read the URL's query string. This in turn requiresuse client, so we also need to convert those pages to client components. This means removingasyncfrom the component, and as a result requires us to move all data fetching intouseEffect()hooks.
- For
- If after all that you manage to get things to compile, you might notice you don't get live data everywhere, some pages simply don't update on reload... This is because those pages haven't been converted to client components yet! When a server component gets compiled as static site, all server-side data fetching gets baked into the output! This means you can refresh as much as you want, the data will always remain the same as what it was when the project was built. So, the solution?
Convert everything to client components!
Since this project is handling a lot of async data that has to be fetched from API providers, I co-developed the DataState library to make all of this simpler, more intuitive, and without the usual bloat that comes with client-side data fetching.
The library mainly consists of a DataState Algebraic Data Type, which always is in either of 3 states:
LoadingState: The data is still being fetched.ErrorState: The fetching process ran into some kind of error. Theerrorfield contains theErrorobject.ValueState: The fetch succeeded, and the resulting data is contained inside thevaluefield of the DataState.
More specifically, the DataState consists of a Root, which contains the actual data fields, and DataStateMethods that extend the Root object into a full DataState.
Here are the Root definitions for reference:
export type LoadingRoot = {
status: 'loading';
value: undefined;
error: undefined | null;
loading: true;
};
export type ValueRoot<T> = {
status: 'value';
value: T;
error: undefined;
loading: false;
};
export type ErrorRoot = {
status: 'error';
value: undefined;
error: Error;
loading: false;
};
export type Root<T> = LoadingRoot | ValueRoot<T> | ErrorRoot;A DataState is initialized with useDataState(), which takes a fetching function and its arguments as input. E.g.:
const blockData = useDataState<Block>({
fetcher: (alchemy, num) => alchemy.core.getBlock(num),
args: [alchemy, blockNumber],
});Once initialized, it can be used directly inside the JSX return statement of the component, e.g.:
return (
<div>
<span>Recipient of block reward:</span>
<blockData.Render
className='w-[11rem]'
loadingPulseColor='bg-(--link-color)'
>
{
(data) =>
<PopoverLink
href={`/${props.network}/address?hash=${data.miner}`}
content={truncateAddress(data.miner, 20)}
popover={data.miner}
className='left-[-37%] top-[-2.6rem] w-78 py-1.5 px-2.5'
/>
}
</blockData.Render>
</div>
);As shown above, every DataState has a .Render() function that receives the fetched data as input, allowing the use of the fetched data directly inside the JSX code:
- Obviously, the Render function only renders the actual data when it is present. If not present, it will render either an
ErrorIndicatorindicating the error that occurred, or aLoadingIndicatororLoadingPulsecomponent when the data is still being fetched. - All of this is very configurable; see the
RenderConfigtype insrc/lib/data-state/types/index.tson line 107. TheRenderConfigonly consists of optional fields to configure theRender()function's output. This means the<blockData.Render>component's props are all optional, and you can pick and choose only the ones that you need.