DEV Community

Cover image for NextJS App Router with Pocketbase SSR setup
tsensei
tsensei

Posted on

NextJS App Router with Pocketbase SSR setup

Pocketbase is an open-source backend solution with a single executable file. It utilizes an embedded SQLite DB and offers features like Realtime Database, Auth, File Storage, and an Admin Dashboard. This makes it a suitable option for small to medium-sized projects where alternatives like Supabase might be excessive. Pocketbase runs efficiently on a small VPS and can handle thousands of users with ease.

With the advent of React Server Components and NextJS's App Router, we now have two environments for running code and rendering UI: server and client. By default, Pocketbase stores the auth token pocketbase_auth in localStorage, which is not accessible on the server. This limitation can be problematic when rendering user-specific data or redirecting to a login route if the user is not authenticated. A workaround is to store the auth token in a cookie, making it accessible across all environments and sent with every request by default.

The Pocketbase JS SDK allows for the creation of custom auth stores for more fine-grained control. In this tutorial, however, we will follow a similar approach to @supabase/ssr, which involves two functions:

  • createBrowserClient : This function instantiates a browser client for client components and synchronizes the cookie with localStorage.
  • createServerClient : This function instantiates a server client, allowing you to pass a cookieStore to access user auth details.

Let's get down to code without further ado :

  1. Download Pocketbase, extract and launch the executable with ./pocketbase serve

  2. This will provide with a API URL which you can write in your .env file. In my case :

NEXT_PUBLIC_POCKETBASE_API_URL=http://127.0.0.1:8090/
Enter fullscreen mode Exit fullscreen mode

The NEXT_PUBLIC_ prefix is necessary for it to be accessible in client environment.

For Typescript IntelliSense support, generate the types using pocketbase-typegen. I have stored it in src/types/pocketbase-types.ts.

With the configurations in place, I have a ./src/lib/pocketbase.ts file to store my functions.

For client components :

import { TypedPocketBase } from '@/types/pocketbase-types';
import PocketBase from 'pocketbase';

let singletonClient: TypedPocketBase | null = null;

export function createBrowserClient() {
    if (!process.env.NEXT_PUBLIC_POCKETBASE_API_URL) {
        throw new Error('Pocketbase API url not defined !');
    }

    const createNewClient = () => {
        return new PocketBase(
            process.env.NEXT_PUBLIC_POCKETBASE_API_URL
        ) as TypedPocketBase;
    };

    const _singletonClient = singletonClient ?? createNewClient();

    if (typeof window === 'undefined') return _singletonClient;

    if (!singletonClient) singletonClient = _singletonClient;

    singletonClient.authStore.onChange(() => {
        document.cookie = singletonClient!.authStore.exportToCookie({
            httpOnly: false,
        });
    });

    return singletonClient;
}
Enter fullscreen mode Exit fullscreen mode

This returns a PocketBase instance using the singleton pattern, it also syncs the cookie with localStorage - so you don't have to think of auth state changes - everything should just work!

For Server components :

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

export function createServerClient(cookieStore?: ReadonlyRequestCookies) {
    if (!process.env.NEXT_PUBLIC_POCKETBASE_API_URL) {
        throw new Error('Pocketbase API url not defined !');
    }

    if (typeof window !== 'undefined') {
        throw new Error(
            'This method is only supposed to call from the Server environment'
        );
    }

    const client = new PocketBase(
        process.env.NEXT_PUBLIC_POCKETBASE_API_URL
    ) as TypedPocketBase;

    if (cookieStore) {
        const authCookie = cookieStore.get('pb_auth');

        if (authCookie) {
            client.authStore.loadFromCookie(`${authCookie.name}=${authCookie.value}`);
        }
    }

    return client;
}
Enter fullscreen mode Exit fullscreen mode

You can choose to pass it a cookieStore to have the user specific auth store loaded or you can ignore the parameter if you are just using it to fetch general data on the server. Examples for both cases is provided below.

With all the code in place, your pocketbase.ts should look like this :

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

let singletonClient: TypedPocketBase | null = null;

export function createBrowserClient() {
    if (!process.env.NEXT_PUBLIC_POCKETBASE_API_URL) {
        throw new Error('Pocketbase API url not defined !');
    }

    const createNewClient = () => {
        return new PocketBase(
            process.env.NEXT_PUBLIC_POCKETBASE_API_URL
        ) as TypedPocketBase;
    };

    const _singletonClient = singletonClient ?? createNewClient();

    if (typeof window === 'undefined') return _singletonClient;

    if (!singletonClient) singletonClient = _singletonClient;

    singletonClient.authStore.onChange(() => {
        document.cookie = singletonClient!.authStore.exportToCookie({
            httpOnly: false,
        });
    });

    return singletonClient;
}

export function createServerClient(cookieStore?: ReadonlyRequestCookies) {
    if (!process.env.NEXT_PUBLIC_POCKETBASE_API_URL) {
        throw new Error('Pocketbase API url not defined !');
    }

    if (typeof window !== 'undefined') {
        throw new Error(
            'This method is only supposed to call from the Server environment'
        );
    }

    const client = new PocketBase(
        process.env.NEXT_PUBLIC_POCKETBASE_API_URL
    ) as TypedPocketBase;

    if (cookieStore) {
        const authCookie = cookieStore.get('pb_auth');

        if (authCookie) {
            client.authStore.loadFromCookie(`${authCookie.name}=${authCookie.value}`);
        }
    }

    return client;
}
Enter fullscreen mode Exit fullscreen mode

Protected Route Guarding

You can have a middleware.ts in the src directory or your project root to guard protected routes and redirect unauthenticated users to a auth route :

import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { createServerClient } from './lib/pocketbase';

// For protected pages
// If auth is not valid for matching routes
// Redirect to a redirect path
export function middleware(request: NextRequest) {
    const redirect_path = 'http://localhost:3000/auth';

    const cookieStore = cookies();

    const { authStore } = createServerClient(cookieStore);

    if (!authStore.isValid) {
        return NextResponse.redirect(redirect_path);
    }
}

export const config = {
    matcher: [
        /*
         * Match all request paths except for the ones starting with:
         * - api (API routes)
         * - _next/static (static files)
         * - _next/image (image optimization files)
         * - favicon.ico (favicon file)
         * - auth (login route)
         * - / (root path)
         */
        '/((?!api|_next/static|_next/image|favicon.ico|auth|^$).*)',
    ],
};
Enter fullscreen mode Exit fullscreen mode

All routes except the ones in matcher should be protected by default now. You will have to manually exclude your intended public routes by modifying the matcher regex.

Examples :

Personalized Route :

import { createServerClient } from '@/lib/pocketbase';
import { cookies } from 'next/headers';

const Page = async () => {
    const cookieStore = cookies();

    const pocketbase = createServerClient(cookieStore);

    const posts = await pocketbase.collection('posts').getFullList();

    return <pre>{JSON.stringify(posts, null, 3)}</pre>;
};

export default Page;

Enter fullscreen mode Exit fullscreen mode

In this example, we have passed our cookie store and in the DB we have this rule in place - List/Search rule : @request.auth.id != "" && @request.auth.id = author.id. As we passed our cookie store, the requests will have a auth store and respond with user specific posts.

For general posts which can be listed by anyone, you can ignore the cookieStore and get the data accordingly. Keep in mind, calling the cookies() function will opt the page into dynamic rendering.

Error Handling :

Sometimes the errors can be hard to debug, in which case, wrap your operation in a try-catch block and log the error.originalError to get more details.

try {
    const posts = await pocketbase.collection('posts').getFullList();
} catch (error: any) {
    console.log(error.originalError);
}
Enter fullscreen mode Exit fullscreen mode

Note : Keep in mind, with the API Rules in place, you can call PocketBase directly from your client in case of any mutations, effectively removing the API Layer we had before. It will update the token automatically - for example in case of logout, the SDK will remove the token from localStorage and our browserClient will sync accordingly, removing the token from cookie as well.

I hope this writing to be of help. You can ask any questions or suggest any fix in case of issue. You can leave a ❤️ & follow me on Github.

Top comments (0)