Login

Handling Usernames in Firestore

J
Jonathan Gamble
Published 9 min read

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.

TypeScript
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.

TypeScript
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.

TypeScript
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.

TypeScript
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.

TypeScript
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#

TypeScript
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.

TypeScript
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.

TypeScript
// 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.

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

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

This reusable hook should work anywhere.

TypeScript
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.


Comments

Share a thought or join the conversation.

Sign In to comment or reply.

Newsletter

Get updates on future FREE tutorials, paid courses, and new blog posts!

Tags

Explore posts by topic.