Create a token-gated website with Whal3s and NextJS

In this tutorial, we will create a token-gated website using Whal3s and NextJS. We will guide you step-by-step through the process of setting up the necessary components and files to achieve this.

Prerequisites:

  • Basic knowledge of React and NextJS.
  • A Whal3s account
  • A utility ID (find your utility ID in your Whal3s account)
  • NodeJS installed

Let's get started!

Step 1: Set up your NextJS project

Create a new NextJS project if you haven't already. You can use the following command:

npx create-next-app your-app-name
cd your-app-name

Accept all default settings. They are fine.

Step 2: Install required packages

Install the following packages:

  • Whal3s JavaScript SDK: @whal3s/whal3s.js
  • Iron-session: iron-session
  • SWR React Hooks library: swr
npm install @whal3s/whal3s.js iron-session swr

Step 3: Configure your environment variables

.env.local

All variables stored in .env.local are used during server side processing and are therefore not available in frontend.
Create an .env.local file in the root folder of your project and add the following variables:

WHAL3S_UTILITY_ID=your-whal3s-utility-id
SECRET_COOKIE_PASSWORD=your-cookie-password

Replace your-whal3s-utility-id with your actual Whal3s utility ID and your-cookie-password with a secure encryption key (min. 32 chars).

The utility ID is generated when setting access control rules using Whal3s. You can do this with a few clicks using the Whal3s App (app.whal3s.xyz [recommended]) or with the API.

next.config.js

To make your utility available in the frontend as well, we need to add them as an environment variable to next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  env: {
    WHAL3S_UTILITY_ID: 'your-whal3s-utility-id'
  }
}

module.exports = nextConfig

Step 4: Setup utility functions

Iron-session

The iron-session related parts of this tutorial are based on the next.js example in the following project: https://github.com/vvo/iron-session/tree/main/examples/next.js-typescript

๐Ÿ“˜

Dive deeper (iron-session)

Feel free to dive deeper if there are session related questions. But to cover the whole process and make it as easy as possible, you can just follow this guide.

Iron-session repository and docs

export default async function fetchJson<JSON = unknown>(
    input: RequestInfo,
    init?: RequestInit,
): Promise<JSON> {
    const response = await fetch(input, init);

    // if the server replies, there's always some data in json
    // if there's a network error, it will throw at the previous line
    const data = await response.json();

    // response.ok is true when res.status is 2xx
    // https://developer.mozilla.org/en-US/docs/Web/API/Response/ok
    if (response.ok) {
        return data;
    }

    throw new FetchError({
        message: response.statusText,
        response,
        data,
    });
}

export class FetchError extends Error {
    response: Response;
    data: {
        message: string;
    };
    constructor({
                    message,
                    response,
                    data,
                }: {
        message: string;
        response: Response;
        data: {
            message: string;
        };
    }) {
        // Pass remaining arguments (including vendor specific ones) to parent constructor
        super(message);

        // Maintains proper stack trace for where our error was thrown (only available on V8)
        if (Error.captureStackTrace) {
            Error.captureStackTrace(this, FetchError);
        }

        this.name = "FetchError";
        this.response = response;
        this.data = data ?? { message: message };
    }
}

// this file is a wrapper with defaults to be used in both API routes and `getServerSideProps` functions
import type { IronSessionOptions } from "iron-session";
import type { User } from "@/pages/api/user";

export const sessionOptions: IronSessionOptions = {
    password: process.env.SECRET_COOKIE_PASSWORD as string,
    cookieName: "iron-session/examples/next.js",
    cookieOptions: {
        secure: process.env.NODE_ENV === "production",
    },
};

// This is where we specify the typings of req.session.*
declare module "iron-session" {
    interface IronSessionData {
        user?: User;
    }
}

import { useEffect } from "react";
import Router from "next/router";
import useSWR from "swr";
import { User } from "@/pages/api/user";

export default function useUser({redirectTo = "", redirectIfFound = false,} = {}) {
    const { data: user, mutate: mutateUser } = useSWR<User>("/api/user");

    useEffect(() => {

        // if no redirect needed, just return (example: already on /)
        // if user data not yet there (fetch in progress, logged in or not) then don't do anything yet
        if (!redirectTo || !user) return;

        if (
            // If redirectTo is set, redirect if the user was not found.
            (redirectTo && !redirectIfFound && !user?.isLoggedIn) ||
            // If redirectIfFound is also set, redirect if the user was found
            (redirectIfFound && user?.isLoggedIn)
        ) {
            Router.push(redirectTo);
        }
    }, [user, redirectIfFound, redirectTo]);

    return { user, mutateUser };
}

๐Ÿ“˜

import { User } from "@/pages/api/user";

Will throw an error for now because we will implement this file at a later stage

fetchJson.ts

Handles requests and transforms them to valid JSON or throws an error.

session.ts

Defines session options for iron-session so you can import them, whenever you need access to the users session.

useUser.ts

Wraps the api request and sets the user data browser side based on the response.

Step 5: Add a decent layout

Install Tailwind

For the layout we will use tailwind.

Install Tailwind CSS

Install tailwindcss and its peer dependencies via npm, and then run the init command to generate both tailwind.config.js and postcss.config.js.

npm i @headlessui/react @heroicons/react react-icons
npm install -D tailwindcss postcss autoprefixer

The next step is to initialise tailwind. To do this, run:

npx tailwindcss init -p

Configure your template paths

Add the paths to all of your template files in your tailwind.config.js file including the Whal3s colors.

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./app/**/*.{js,ts,jsx,tsx}",
    "./pages/**/*.{js,ts,jsx,tsx}",
    "./components/**/*.{js,ts,jsx,tsx}",

    // Or if using `src` directory:
    "./src/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {
      colors: {
        whal3s: {
          '50': '#edf0ff',
          '100': '#dee5ff',
          '200': '#c4cdff',
          '300': '#a0acff',
          '400': '#7a80ff',
          '500': '#5d5afa',
          '600': '#503eef',
          '700': '#422fd3',
          '800': '#3729aa',
          '900': '#302986',
        },
      },
    },
  },
}


Add the Tailwind directives to your CSS

Add the @tailwind directives for each Tailwindโ€™s layers to your globals.css file.

@tailwind base;
@tailwind components;
@tailwind utilities;

๐Ÿ“˜

Dive deeper (Tailwind)

To dive deeper into Next.js and Tailwind, take a look at https://tailwindcss.com/docs/guides/nextjs

Implement Toast messages (react-hot-toast)

Install react-hot-toast

npm i react-hot-toast

Implement react-hot-toast

Place the <Toaster/>component in the _app.tsx file.

import '@/styles/globals.css'
import type {AppProps} from 'next/app'
import {Toaster} from "react-hot-toast";

export default function App({Component, pageProps}: AppProps) {
    return (
        <>
            <div>
                <Toaster
                    position="top-right"
                    reverseOrder={false}
                />
            </div>
            <Component {...pageProps} />
        </>
    )
}

๐Ÿ“˜

Dive deeper (react-hot-toast)

Find the documentation for react-hot-toasts here.

Implement template

Now that you have installed all required packages you can copy and paste the code for Navbar and Layout and Wrap the Dynamic Component in app.tsx with the freshly created layout.

Additionally we add a button component to have a uniformly design.

import React, {Fragment} from 'react';
import {Disclosure, Menu, Transition} from "@headlessui/react";
import {Bars3Icon, XMarkIcon, UserIcon} from "@heroicons/react/24/outline";
import fetchJson from "@/lib/fetchJson";
import Router, {useRouter} from "next/router";

const navigation = [
    {name: 'Login', href: '/'},
    {name: 'Gated', href: '/gated/video'},
]


function classNames(...classes: string[]) {
    return classes.filter(Boolean).join(' ')
}

const Navbar = () => {

    const router = useRouter();
    const logout = async () => {

        // Call logout API route to end session
        await fetchJson("/api/logout", {
            method: "POST",
            headers: {"Content-Type": "application/json"},
        })
        Router.push('/');

    }

    return (
        <Disclosure as="nav" className="border-b border-gray-200 bg-white">
            {({open}) => (
                <>
                    <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
                        <div className="flex h-16 justify-between">
                            <div className="flex">
                                <a className="flex flex-shrink-0 items-center" href={'/'}>
                                    <img
                                        className="block h-8 w-auto lg:hidden"
                                        src="https://whal3s-assets.s3.eu-central-1.amazonaws.com/logos/Whal3s_black.png"
                                        alt="Whal3s"
                                    />
                                    <img
                                        className="hidden h-8 w-auto lg:block"
                                        src="https://whal3s-assets.s3.eu-central-1.amazonaws.com/logos/Whal3s_black.png"
                                        alt="Whal3s"
                                    />
                                </a>
                                <div className="hidden sm:-my-px sm:ml-6 sm:flex sm:space-x-8">
                                    {navigation.map((item) => (
                                        <a
                                            key={item.name}
                                            href={item.href}
                                            className={classNames(
                                                router.pathname === item.href
                                                    ? 'border-whal3s-500 text-gray-900'
                                                    : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700',
                                                'inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium'
                                            )}
                                            aria-current={router.pathname === item.href ? 'page' : undefined}
                                        >
                                            {item.name}
                                        </a>
                                    ))}
                                </div>
                            </div>
                            <div className="hidden sm:ml-6 sm:flex sm:items-center">

                                {/* Profile dropdown */}
                                <Menu as="div" className="relative ml-3">
                                    <div>
                                        <Menu.Button
                                            className="flex max-w-xs items-center rounded-full bg-white text-sm focus:outline-none focus:ring-2 focus:ring-whal3s-500 focus:ring-offset-2 bg-gray-200 p-1">
                                            <span className="sr-only">Open user menu</span>
                                            <UserIcon className="h-8 w-8 rounded-full"/>
                                        </Menu.Button>
                                    </div>
                                    <Transition
                                        as={Fragment}
                                        enter="transition ease-out duration-200"
                                        enterFrom="transform opacity-0 scale-95"
                                        enterTo="transform opacity-100 scale-100"
                                        leave="transition ease-in duration-75"
                                        leaveFrom="transform opacity-100 scale-100"
                                        leaveTo="transform opacity-0 scale-95"
                                    >
                                        <Menu.Items
                                            className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
                                            <Menu.Item>
                                                {({active}) => (
                                                    <a
                                                        href={'#'}
                                                        onClick={logout}
                                                        className={classNames(
                                                            active ? 'bg-gray-100' : '',
                                                            'block px-4 py-2 text-sm text-gray-700'
                                                        )}
                                                    >
                                                        Logout
                                                    </a>
                                                )}
                                            </Menu.Item>
                                        </Menu.Items>
                                    </Transition>
                                </Menu>
                            </div>
                            <div className="-mr-2 flex items-center sm:hidden">
                                {/* Mobile menu button */}
                                <Disclosure.Button
                                    className="inline-flex items-center justify-center rounded-md bg-white p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-whal3s-500 focus:ring-offset-2">
                                    <span className="sr-only">Open main menu</span>
                                    {open ? (
                                        <XMarkIcon className="block h-6 w-6" aria-hidden="true"/>
                                    ) : (
                                        <Bars3Icon className="block h-6 w-6" aria-hidden="true"/>
                                    )}
                                </Disclosure.Button>
                            </div>
                        </div>
                    </div>

                    <Disclosure.Panel className="sm:hidden">
                        <div className="space-y-1 pt-2 pb-3">
                            {navigation.map((item) => (
                                <Disclosure.Button
                                    key={item.name}
                                    as="a"
                                    href={item.href}
                                    className={classNames(
                                        router.pathname === item.href
                                            ? 'border-whal3s-500 bg-whal3s-50 text-whal3s-700'
                                            : 'border-transparent text-gray-600 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-800',
                                        'block border-l-4 py-2 pl-3 pr-4 text-base font-medium'
                                    )}
                                    aria-current={router.pathname === item.href ? 'page' : undefined}
                                >
                                    {item.name}
                                </Disclosure.Button>
                            ))}
                        </div>
                        <div className="border-t border-gray-200 pt-4 pb-3">
                            <div className="flex items-center px-4">
                                <div className="flex-shrink-0">
                                    <UserIcon className="h-10 w-10 rounded-full "/>
                                </div>
                            </div>
                            <div className="mt-3 space-y-1">
                                <Disclosure.Button
                                    as="a"
                                    href={'#'}
                                    onClick={logout}
                                    className="block px-4 py-2 text-base font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-800"
                                >
                                    Logout
                                </Disclosure.Button>
                            </div>
                        </div>
                    </Disclosure.Panel>
                </>
            )}
        </Disclosure>
    );
};

export default Navbar;

import React from 'react';
import Navbar from "@/components/Navbar";
const Layout = (props:any) => {
    return (
        <div className="min-h-full">
            <Navbar/>
            <div className="py-10">
                {props.header && <header>
                    <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
                        <h1 className="text-3xl font-bold leading-tight tracking-tight text-gray-900">{props.header}</h1>
                    </div>
                </header>}

                <main>
                    <div className="mx-auto max-w-7xl sm:px-6 lg:px-8">
                        {props.children}
                    </div>
                </main>
            </div>
        </div>
    );
};


export default Layout;

import '@/styles/globals.css'
import type {AppProps} from 'next/app'
import Layout from "@/components/Layout";
import {Toaster} from "react-hot-toast";

export default function App({Component, pageProps}: AppProps) {
    return (
        <>
            <div>
                <Toaster
                    position="top-right"
                    reverseOrder={false}
                />
            </div>
            <Layout>

                <Component {...pageProps} />
            </Layout>
        </>
    )
}

import React from 'react';
import {ImSpinner2} from "react-icons/im";

type Props = {
    onClick?: React.MouseEventHandler<HTMLButtonElement>,
    className?: string,
    children: React.ReactNode,
    disabled?: boolean,
    isLoading?: boolean
}
const Button = ({onClick, children, className = '', disabled = false, isLoading = false} : Props) => {
    if (isLoading)
        disabled = true;
    return (
        <button
            disabled={disabled}
            type="button"
                onClick={onClick}
                className={`${className} inline-flex justify-center items-center rounded-md border border-transparent bg-whal3s-600 disabled:bg-whal3s-500 px-6 py-3 text-base font-medium text-white shadow-sm hover:bg-whal3s-700 focus:outline-none focus:ring-2 focus:ring-whal3s-500 focus:ring-offset-2`}>
            {isLoading &&  <ImSpinner2 className="-ml-1 mr-2 h-5 w-5 animate-spin" aria-hidden="true" /> }
            {children}
        </button>

    );
};

export default Button;

Step 6: Implement Login component and its' subcomponents

Login subcomponents

The make the login flow more readable we divide each utility step into a separated file and combine them later in the Login.tsx.

import React from 'react';
import Button from "@/components/Button";

const Uninitialized = () => {

    //Nothing to do here, utility initializes itself
    return (
        <Button
            isLoading={true}
            className=""
            onClick={() => {}}
           >Initializing</Button>
    );
};

export default Uninitialized;

import React, {useState} from 'react';
import Button from "../Button";
import {NftValidationUtility} from "@whal3s/whal3s.js";
import toast from "react-hot-toast";

type Props = {
    utility: NftValidationUtility
}
const ConnectWalletButton = ({utility}: Props) => {
    const [loading, setLoading] = useState(false);
    const connectWallet = () => {
        setLoading(true)
        utility.connectWallet()
            .catch((e: any) => {
                toast.error( e.message)
            })
            .finally(() => {
                setLoading(false)
            })
    }

    return (
        <Button
            isLoading={loading}
            className=""
            onClick={() => {
                connectWallet()
            }}>Connect Wallet</Button>
    );
};

export default ConnectWalletButton;

import React, {useState} from 'react';
import Button from "../Button";
import {NftValidationUtility} from "@whal3s/whal3s.js";
import toast from "react-hot-toast";

type Props = {
    utility: NftValidationUtility
}
const SignMessageButton = ({utility}: Props) => {
    const [loading, setLoading] = useState(false);
    const sign = () => {
        setLoading(true)
        utility.sign()
            .catch((e: any) => {
                toast.error(e.message)
            })
            .finally(() => {
                setLoading(false)
            })
    }
    return (

        <Button
            isLoading={loading}
            className=""
            onClick={() => {
                sign()
            }}>Sign Message</Button>

    );
};

export default SignMessageButton;

Uninitialized.tsx

This component is shows while the utility data is being fetched

ConnectWalletButton.tsx

This component is shown after the utility data is fetched and the user hasn't connected its wallet yet.

SignMessageButton.tsx

This component is shown after the user has connected its wallet and hasn't signed the message yet (message is fetched automatically from the Whal3s Server)

Login component

Create a new file Login.tsx in the src/components folder and paste the provided Login.tsx code. This file contains the logic for initializing Whal3s, creating a validation utility, handling user login steps, and sending a login request to the API. After the user was mutated, redirect to '/gated/content', a page that we will create later in this tutorial.

import React, {useEffect, useState} from 'react';
import Whal3s, {NftValidationUtility} from '@whal3s/whal3s.js'
import Button from "@/components/Button";
import Uninitialized from "@/components/login/Uninitialized";
import ConnectWalletButton from "@/components/login/ConnectWalletButton";
import SignMessageButton from "@/components/login/SignMessageButton";
import fetchJson from "@/lib/fetchJson";
import useUser from "@/lib/useUser";
import toast from "react-hot-toast";

const Login = () => {

    const {mutateUser} = useUser({
        redirectTo: "/gated/content", // redirect to /gated/video after login
        redirectIfFound: true,
    });

    const [utility, setUtility] = useState<NftValidationUtility | undefined>(undefined); // utility instance

    const [step, setStep] = useState<number>(0); // current step of the utility

    const [loading, setLoading] = useState(false); // loading state

    // initialize the utility
    const init = async () => {
        // initialize the Whal3s object and set the account center to bottom right
        const whal3s = new Whal3s({
            accountCenter: {
                mobile: {
                    enabled: true, // enable the account center on mobile
                    position: 'bottomRight', // position the account center to bottom right
                },
                desktop: {
                    enabled: true, // enable the account center on desktop
                    position: 'bottomRight', // position the account center to bottom right
                },
            }
        });
        // create the validation utility based on the utility id you saved to .env.local
        const _utility = await whal3s.createValidationUtility(process.env.WHAL3S_UTILITY_ID ?? '')
        // add event listeners to the utility to update the state whenever the utility goes over to the next step
        _utility.addEventListener('stepChanged', (event) => {
            setUtility(_utility)
            setStep(_utility.step)
        })
        // add event listener to the utility to login the user when the signature is signed by the user
        _utility.addEventListener('signed', () => {
            setUtility(_utility)
            login(_utility)
        })
        setStep(_utility.step)
        setUtility(_utility)
    }

    // login the user
    const login = async (utility: NftValidationUtility) => {
        setLoading(true)
        try {
            // call the login endpoint with the signature and wallet address and set the user according to the response
            const response = await mutateUser(
                await fetchJson("/api/login", {
                    method: "POST",
                    headers: {"Content-Type": "application/json"},
                    body: JSON.stringify({
                        signature: utility?.signature,
                        walletAddress: utility?.wallet?.address,

                    }),
                }),
                false,
            );
            if (!response?.isLoggedIn)
                toast.error( 'Login failed, Wallet not eligible')


        } catch (e: any) {
            toast.error( 'Wallet not eligible')
        }

        setLoading(false)
    }

    useEffect(() => {
        // initialize the utility on component mount
        init()
    }, [])


    return (
        <div>
            {step === NftValidationUtility.STEP_UNINITIALIZED && <Uninitialized/>}
            {step === NftValidationUtility.STEP_INITIALIZED && utility && <ConnectWalletButton utility={utility}/>}
            {step >= NftValidationUtility.STEP_WALLET_CONNECTED && utility && !utility?.signature &&
                <SignMessageButton utility={utility}/>}
            {utility?.signature && <Button
                onClick={() => {
                    login(utility)
                }}
                disabled={loading}
                isLoading={loading}
            >{loading ? 'Logging in' : 'Log in'}</Button>}
        </div>
    );
};

export default Login;

๐Ÿ“˜

You can find more informations in the "Get started with Whal3s.js" Section

Get started with Whal3s

Step 7: Implement API routes

User

Create a new file user.ts in the src/pages/api folder and paste the provided code. This file returns user session data and exports the User definition.

import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";

// User definition
export type User = {
    isLoggedIn: boolean;
    walletAddress: string;
    signature: string;
};

export default withIronSessionApiRoute(
    function userRoute(req, res) {

        if (req.session.user) {
            // in a real world application you might read the user id from the session and then do a database request
            // to get more information on the user if needed
            res.json({
                ...req.session.user,
                isLoggedIn: true,
            });
        } else {
            res.json({
                isLoggedIn: false,
                walletAddress: "",
                signature: "",
            });
        }
    },
    sessionOptions
);

Login

Create a new file login.ts in the /pages/api folder and paste the provided code. This file handles the login route, verifies the signature, and checks the wallet eligibility using Whal3s API.

import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";

export default withIronSessionApiRoute(
    async function loginRoute(req, res) {

        const {walletAddress, signature} = req.body; // extract the wallet address and signature from the request body

        const options = {method: 'GET', headers: {accept: 'application/json'}};

        try {
            // validate the signature
            const signatureValidationResponse = await fetch(`https://app.whal3s.xyz/api/v0/signature-messages?utility_id=${process.env.WHAL3S_UTILITY_ID}&wallet_address=${walletAddress}&signature=${signature}`, options)
            if (signatureValidationResponse.status !== 200)
                throw 'Invalid signature'

            // validate the wallet address, check if wallet matched utility conditions
            const eligibilityResponse = await fetch(`https://app.whal3s.xyz/api/v0/nft-validation-utilities/${process.env.WHAL3S_UTILITY_ID}/wallet/${walletAddress}`)
            const eligibilityResponseJson = await eligibilityResponse.json()

            if (eligibilityResponse.status !== 200 || !eligibilityResponseJson.valid)
                throw 'Wallet not eligible'

            // if the signature and wallet address are valid, create a user object and save it to the session
            const user = {
                isLoggedIn: true,
                walletAddress: walletAddress,
                signature: signature,
            };
            req.session.user = user;
            await req.session.save();
            res.json(user);

        } catch (error){
            // if there is an error, set the user object to an empty object - kill the session
            req.session.user = {
                isLoggedIn: false,
                walletAddress: '',
                signature: '',
            };
            await req.session.save();
            res.status(403).json({message: (error as Error).message});
        }

    },
    sessionOptions
);

Logout

Create a new file logout.ts in the /pages/api folder and paste the provided code. This file handles user logout by destroying the session.

import { withIronSessionApiRoute } from "iron-session/next";
import {sessionOptions} from "@/lib/session";

export default withIronSessionApiRoute(
    function logoutRoute(req, res) {
        // destroy the session
        req.session.destroy();
        res.json({
            isLoggedIn: false,
            walletAddress: "",
            signature: "",
        });
    },
    sessionOptions
);

Step 8: Use the Login component

Replace the content of the pages/index.tsx file with the following code to render the Login component:

import Head from 'next/head'
import dynamic from "next/dynamic";
import Button from "@/components/Button";
import React from "react";

const DynamicLogin = dynamic(() => import('@/components/Login'), {
    ssr: false,
    loading: () => <Button
        isLoading={true}
        className=""
        onClick={() => {
        }}
    >Initializing</Button>,
})

export default function Home() {
  return (
    <>
      <Head>
        <title>Next.js x Whal3s</title>
        <meta name="description" content="Next.js token gating with Whal3s" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <main >
          <DynamicLogin/>
      </main>
    </>
  )
}

๐Ÿ“˜

Dynamic components

Since Next.js renders the pages server-side, we have to exclude our login component because the Whal3s object it contains needs access to the browser context and can therefore only be rendered if it exists.

Read more here.

Step 9: Add token-gated content

Create page

Not that we have set up all parts that are needed to authorize the user we need some content we can gate.

To do so, create a page in src/pages/gated/content.tsx.

import React from 'react';

const Content = () => {
    return (
        <div>
            <p>You can only see this content if you are logged in.</p>
        </div>
    );
};

export default Content;

Restrict access

The final step is to restrict access to this page. The easiest way to do so ist by setting up a middleware that checks, whenever a page is requested, the eligibility of the logged in user.

// /middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getIronSession } from "iron-session/edge";
import {sessionOptions} from "@/lib/session";

export const middleware = async (req: NextRequest) => {
    const res = NextResponse.next();
    const session = await getIronSession(req, res, sessionOptions);
    const { user } = session;
    
    //If the user is not logged in and is trying to access a gated page, redirect to /unauthorized
    if (req.nextUrl.pathname.startsWith('/gated') && !user?.isLoggedIn) {
        return NextResponse.redirect(new URL('/unauthorized', req.url)) // redirect to /unauthorized page
    }

    return res;
};

Feel free to change what happens if the user is not eligible (Line 14). This example redirects the user to the /authorized page still have to create if you haven't yet.

Step 10: Test your token-gated website

Start your development server with:

npm run dev

Visit http://localhost:3000 in your browser. You should see the login page. After logging in with an eligible wallet, you will be redirected to the token-gated content.

That's it! You've successfully created a token-gated website using Whal3s and NextJS.

Complete project

You can find the complete project on github.