Setting up Next.JS and PocketBase for Authentication

Setting up Next.JS and PocketBase for Authentication

If you are here, you most likely had a problem setting up PocketBase and Next.js 13 for authentication. Don't worry, I got you. You don't have to suffer the same pain I went through. If you prefer to watch instead of read, click here to watch this on YouTube.

This article is not intended as a Next.js tutorial for beginners if that is what you are looking for. This article is for those who are familiar with Next.js but want a simple and easy way to add authentication.

PocketBase is an open-source Firebase alternative that you can self-host or put on VPS (Virtual Private Server) and access it everywhere on the internet.

Setup Project

We need to create the Next.js and we use the create-next-app command.

npx create-next-app@latest

This command will prompt you to provide the details of the project, you can type your own or select the default options.

We also need to install the following dependencies.

yarn add pocketbase cookies-next

We'll use cookies-next to clear the cookies when the user logging out.

Create DB Wrapper

In this section, we create a wrapper class that will simplify access to PocketBase. The comments in the code explain each part and what it does.

// src/db/index.ts

import PocketBase from 'pocketbase';
import { ReadonlyRequestCookies } from 'next/dist/server/web/spec-extension/adapters/request-cookies';

export const POCKET_BASE_URL = "http://127.0.0.1:8090";

export class DatabaseClient {
    // the instance of PocketBase
    client: PocketBase;

    constructor () {
        // instantiate PocketBase before we use
        this.client = new PocketBase(POCKET_BASE_URL);
    }

    // authenticate handles the authentication of the user
    async authenticate (email: string, password: string) {
        try {
            const result = await this.client.collection("users").authWithPassword(email, password);
            // If there is no token in the result, it means something went wrong
            if (!result?.token) {
                throw new Error("Invalid email or password");
            }
            return result;
        } catch (err) {
            console.error(err);
            throw new Error("Invalid email or password");
        }
    }

    // register handles the creation of a new user
    async register (email: string, password: string) {
        try {
            // We provide only the minimum required fields by user create method
            const result = await this.client.collection("users").create({
                email,
                password,
                passwordConfirm: password,
            });

            return result;
        } catch (err) {

        }
    }

    // isAuthenticated takes cookieStore from the request to check for the required tokens in the cookie
    async isAuthenticated(cookieStore: ReadonlyRequestCookies) {
        const cookie = cookieStore.get('pb_auth');
        if (!cookie) {
            return false;
        }

        // loadFromCookie applies the cookie data before checking the user is authenticated
        this.client.authStore.loadFromCookie(cookie?.value || '');
        return this.client.authStore.isValid || false
    }

    // getUser is similar to isAuthenticated, the only difference is the returned data type
    async getUser(cookieStore: ReadonlyRequestCookies) {
        const cookie = cookieStore.get('pb_auth');
        if (!cookie) {
            return false;
        }

        this.client.authStore.loadFromCookie(cookie?.value || '');
        return this.client.authStore.model ;
    }
}

// We create an instance of the DatabaseClient that can be used throughout the app.
export const db = new DatabaseClient();

export default db;

Enforce Protected Routes

To keep the users from accessing protected routes, we need to add those rules in the middleware.ts file. This file is a special file used by Next.js and is called before each request to all the URL paths.

We can filter the paths we need to ignore in the matcher field of the config we export from the middleware.ts file.

export const config = {
    matcher: [
        '/((?!api|_next/static|_next/image|favicon.ico).*)',
    ],
};

In the matcher above, we ignore any URL route that starts with api, _next/static, _next/image and favicon.ico.

The most important part of the middleware.ts file is the middleware function which takes the HTTP request as the first argument. That provides us with all the details we need to make decisions of whether to let the request continue or redirect to the login page for authentication.

Here is the full middleware.ts file:

import { NextRequest, NextResponse } from "next/server";
import db from "./db";

export async function middleware(request: NextRequest) {
    // We print the request method and URL in the logs to see what's happening
    console.log(`[middleware] ${request.method} ${request.url}`);
    // To see more about db.isAuthenticated, check file src/db/index.ts
    const isLoggedIn = await db.isAuthenticated(request.cookies as any);
    if (request.nextUrl.pathname && request.nextUrl.pathname.startsWith("/auth")) {
        // If already logged in and the request is to go to the login page,
        // Skip it and redirect to the home page.
        if (isLoggedIn) {
            return NextResponse.redirect(new URL("/", request.url));
        }
        return;
    }

    // Anything after this is for protected routes, in our case,
    // Only the routes that start with /auth are not protected.
    // If you have other pages that are not protected you can
    // Handle them before the isLoggedIn check below.

    // Check if the user is logged in
    if (!isLoggedIn) {
        // If not logged in, redirect them to the login page.
        return NextResponse.redirect(new URL("/auth/login", request.url));
    }

    // Continue without any request changes.
    return NextResponse.next();
}

export const config = {
    matcher: [
        '/((?!api|_next/static|_next/image|favicon.ico).*)',
    ],
};

Login and Sign Up Pages

The login page is a simple React component, but on Next 13 and forward we need to use 'use client' at the beginning of the file to have the ability to use normal React hooks, such as useState, here are the full contents of the login page component:

// src/app/auth/login/page.tsx
'use client';
import { useRouter } from 'next/navigation';
import React from 'react'

function LoginPage() {
    const route = useRouter();
    const [email, setEmail] = React.useState<string>('');
    const [password, setPassword] = React.useState<string>('');
    const [error, setError] = React.useState('');

    const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
        event.preventDefault();

        try {
            const form = {email, password};
            const response = await fetch('/api/auth/login', {
                method: 'POST',
                headers: {'Content-Type': 'application/json'},
                body: JSON.stringify(form)
            });
            if (!response.ok) {
                setError('Failed to authenticate user');
                return;
            };
            const data = await response.json();
            if (data?.token) {
                route.push('/');
            } else {
                setError('Failed to authenticate user');
            }
        } catch (err) {
            setEmail('Failed to authenticate user');
        }
    };

    return (
        <div>
            <h1>Login</h1>

            <form onSubmit={onSubmit}>
                <div>
                    <label htmlFor="email">Email</label>
                    <input
                        type="email"
                        id="email"
                        value={email}
                        onChange={e => setEmail(e.target.value || '')}
                    />
                </div>

                <div>
                    <label htmlFor="password">Password</label>
                    <input
                        type="password"
                        id="password"
                        value={password}
                        onChange={e => setPassword(e.target.value || '')}
                    />
                </div>

                <button type="submit">Login</button>

                {error && <p className='error'>{error}</p>}
            </form>

            <div className="row">
                <div className="col-12">
                    <p>Don't have an account? <a href="/auth/signup">Sign up</a></p>
                </div>
            </div>
        </div>
    )
}

export default LoginPage

The Signup page content is similar, except for the API URL in the onSubmit event handler.

We need to get to the API routes to create both /api/auth/login and /api/auth/signup.

API Routes

Next.js 13 has a new way of creating API endpoints by creating api folder within the app folder. We created nested folders inside the api folder to be able to create the endpoints we need. At the end, we need a file with the name route.ts which has a bunch of functions with the HTTP verbs as their names.

The signup endpoint is very simple because of the wrapper we created for the database.

Here is the full content of the Signup endpoint:

// src/app/api/auth/signup/route.ts
import db from "@/db";
import { cookies } from 'next/headers';
import { NextResponse } from "next/server";

export async function POST(request: Request) {
    try {
        const { email, password } = await request.json();
        const result = await db.register(email, password);

        return NextResponse.json(result);
    } catch (err: any) {
        return new Response(
            JSON.stringify({ error: err.message || err.toString() }),
            {
                status: 500,
                headers: {
                    'Content-Type': 'application/json',
                },
            }
        )
    }
}

The Login endpoint is a little bit different and here is how it looks:

// src/app/api/auth/login/route.ts
import db from "@/db";
import { cookies } from 'next/headers';
import { NextResponse } from "next/server";

export async function POST(request: Request) {
    try {
        const { email, password } = await request.json();
        const result = await db.authenticate(email, password);
        const {record, token} = result;
        record.token = token;
        cookies().set('pb_auth', db.client.authStore.exportToCookie());

        return NextResponse.json(record);
    } catch (err: any) {
        return new Response(
            JSON.stringify({ error: err.message || err.toString() }),
            {
                status: 500,
                headers: {
                    'Content-Type': 'application/json',
                },
            }
        )
    }
}

In this endpoint, we only handle the POST method, then we get the email and password from the JSON form we submitted from the Login form by calling the request.json() async function.

Conclusion

We have learned in this article, how we can use PocketBase as an authentication backend with NextJS 13, we also built simple signup and login pages and the endpoints that handle those tasks.

The full code is on GitHub and here is the link.