Home
> Handling Usernames in Firestore
Get updates on future FREE course and blog posts!
Subscribe

Handling Usernames in Firestore

9 min read

Jonathan Gamble

jdgamble555 on Sunday, September 8, 2024 (last modified on Sunday, September 8, 2024)

Most modern apps need usernames. We don’t want to share our email addresses publicly, using a long user ID would look odd. Firebase doesn’t handle usernames out of the box, but you can use them with a few minor modifications.

Firestore Username

TL;DR#

When adding a new username, a separate usernames collection will complement the users collection. This will enforce uniqueness and allow for easy searchability. To save speed and reads, you should also add the username to a custom claim that can be accessible directly in the ID Token.

Collections#

Depending on our app, our username could have three main points of reference:

  1. usernames/{username} document
  2. The field username in the users/{user_id} document
  3. In the custom claim of the currentUser object

📝 We need the usernames collection to maintain distinct usernames. Firestore does not handle unique values, so we must use a document ID to emulate this behavior.

Adding a Username#

When we add a username, we should add it to both collections simultaneously. Here, I use firebase-admin, but you could also do this on the client.

	const batch = adminDB.batch();

batch.set(
    adminDB.doc(`usernames/${username}`),
    {
        uid,
        username
    }
);

batch.set(
    adminDB.doc(`users/${uid}`),
    {
        username
    },
    { merge: true }
);

await batch.commit();

However, we should also delete the old username to keep our records clean. This allows you to use the same method for updating a username as for adding a new one.

	const uid = token.uid;

// get current username if exists
const querySnap = await adminDB
    .collection('usernames')
    .where('uid', '==', uid)
    .get();

const batch = adminDB.batch();

// delete current username if exists
if (!querySnap.empty) {

    querySnap.docs.forEach((snap) => {
        batch.delete(
            adminDB.doc(`usernames/${snap.id}`)
        )
    });
}

// add new username
try {
    batch.set(
        adminDB.doc(`usernames/${username}`),
        {
            uid,
            username
        }
    );

    batch.set(
        adminDB.doc(`users/${uid}`),
        {
            username
        },
        { merge: true }
    );

    await batch.commit();

} catch (e) {
    if (e instanceof FirebaseError) {
        error(400, e.message);
    }
}

📝 The error() method is in SvelteKit, but you could use whatever method your SSR framework uses to handle errors. You could also use a callable function if your app is only client-side.

Custom Claims#

It is extremely easy to add a username custom claim on the backend.

	try {
    await adminAuth.setCustomUserClaims(uid, {
        username
    });
} catch (e) {
    if (e instanceof FirebaseError) {
        error(400, e.message);
    }
}

⚠️ You cannot create custom claims on the client side; hence, it is recommended that you use the backend for the entire function.

📝 Keeping everything on the client side prevents you from having complex Firestore Rules when adding a username. If you keep these functions on the server, make the usernames collection read-only. See Firestore Rules.

Refreshing Token on the Client#

Anytime you update a custom claim, you should update the client token.

	await currentUser.getIdToken(true);

This should be done after your backend function is safely finished running.

Verify the User#

Before adding any custom claims, you should always verify the user. You must pass the idToken to the server or Firebase function to do this.

	export const verifyIdToken = async (idToken: string) => {
    try {
        const token = await adminAuth.verifyIdToken(idToken);
        return {
            token,
            error: null
        };
    } catch (e) {
        if (e instanceof FirebaseError) {
            return {
                error: e.message,
                token: null
            };
        }
    }
    return {
        error: null,
        token: null
    };
};

Usage#

	const { token, error: tokenError } = await verifyIdToken(idToken);

if (!token || tokenError) {
    error(401, 'Unauthorized!');
}

Checking for Valid Username#

If you want to check for a valid username, you check for a username in the usernames collection.

	export const usernameAvailable = async (
    username: string
) => {
    try {
        const snap = await getDoc(
            doc(db, 'usernames', username)
        );
        return {
            available: !snap.exists(),
            error: null
        };
    } catch (e) {
        if (e instanceof FirebaseError) {
            return {
                error: e.message,
                available: null
            };
        }
    }
    return {
        error: null,
        available: null
    };
};

Debounce#

You can check while typing in an input field, but you should wait a few seconds before fetching Firebase to prevent unnecessary reads.

	// reusable debounce function
export function useDebounce<F extends (
    ...args: Parameters<F>
) => ReturnType<F>>(
    func: F,
    waitFor: number,
): (...args: Parameters<F>) => void {
    let timeout: NodeJS.Timeout;
    return (...args: Parameters<F>): void => {
        clearTimeout(timeout);
        timeout = setTimeout(() => func(...args), waitFor);
    };
}

Then you call it with oninput. This varies depending on your framework.

	const debouceUsername = useDebounce(() => { // do something here });

<input oninput={debounceUsername} ... />

This reusable hook should work anywhere.

	const { available, error } = await usernameAvailable(username);

Login with Username Feature#

It is worth noting that you could also simulate a login with username feature, even though this is NOT supported out-of-the-box, by simply using the username to fetch the user's UID. This would have to be done on the backend, and would use firebase-admin as well.

Example#

Demo: Vercel Serverless

Repo: GitHub

📝 Remember firebase-admin will ONLY work in NodeJS Environments. Use a callable Firebase Function to deploy to Bun, Vercel Edge, Cloudflare, or Deno environments.


Related Posts

© 2024 Code.Build