loading...
Cover image for Public bookmark list and URL shortener with Fauna, Next.js and Auth0

Public bookmark list and URL shortener with Fauna, Next.js and Auth0

fgiuliani profile image Facundo Giuliani Originally published at fgiuliani.com ・15 min read

Serverless architecture has gone far and beyond in these days. If we want to create a modern web application, we have a lot of products, tools and services that make our lives easier and prevent us from reinventing the wheel. Frameworks, authentication platforms, headless content management systems, databases. We have a huge amount of tools that will help us to build fast, stable, and secure web applications, with a great developer experience. No backend configuration, no server management: Build serverless applications focusing on what we want to create.

In this demo we will create a platform where a user can create bookmarks with shortened URLs that can be used as shortcuts to the saved pages. The user will be authenticated to create those bookmarks, and shortened URLs will be exposed as a public bookmark list, selecting which links should be displayed publicly and which links should be private.

To create the platform we will use several products:

  • Next.js, the React Framework, will be used to handle server- and client-side rendering for the pages of the platform. It will also allow us to create API routes that will be deployed as lambdas/serverless functions.
  • Fauna will be used as the database where we will store all the bookmarks and their details.
  • Auth0 will handle user authentication and authorization.

Keep in mind that all of these products are free at a small scale, so you won't have to pay anything to start creating and playing with this project. Let's start!

As I don't want to create a super long post, I will omit some details not fully related to the idea of the article (css, styling, form advanced validations, login/signup custom pages, etc).
If you want to see the final code of the demo, you can clone and run: https://github.com/fgiuliani/fauna-next-auth0-example.


SignUp for a Fauna Account

Fauna is a global serverless database platform that offers you a fast, consistent and reliable service for your data operations. You can sign up for a free account and start creating and setting up a new database in a simple way.

What's also cool about Fauna is its integrations with modern platforms and frameworks. We will use a JavaScript driver to query our database, with simple methods and easy setup. You don't have to worry about configuration or anything; in a few steps you will be querying data from a NoSQL document collection.
If you don't have a Fauna Account yet, go to the sign-up page to create one.

Create a new database

Once you've created your account and logged in, click on "New Database". Name your database whatever you want. Maybe "bookmarks" would be appropriate.

Create a new collection

On the left database menu, click on "Collections" and then on "New Collection". Let's call it "Bookmarks". The collection will be populated with documents from the app, so you don't need to add anything here.

Create indexes

From the app, we will use indexes to help us to query the bookmarks that we want to create, load, delete, or use. Let's create them by clicking on "Indexes" on the left sidebar, and then on "New Index". Let's create two:

  • getBookmarksByUsername: This index will help us get all the bookmarks for a specific user. This will be used to generate the bookmark list. The index should be created over Bookmarks collection, and we should add "username" as the term.
  • getBookmarkByUsernameAndSlug: This index will be used to retrieve one specific bookmark, filtering by user and "slug". We will use this to redirect users to the URL that we shortened. We will also use the index when we delete a bookmark. The index should be created over Bookmarks collection, and we should add two terms: "username" and "slug".

Create Server Key

In order to access the database, we will need a key that we'll config in our Next.js application. In the left sidebar, go to "Security" and click on "New Key". Select "Server" role and save. After generating the key, copy it in a safe place. You won't see this key again!


SignUp for an Auth0 Account

If you don't know about Auth0 yet, I recommend you to read about the platform and all the different products they offer. In this demo, we will use their Universal Login feature. We don't need to worry about creating login pages or handling user databases, hashes, or encryption. We rely on Auth0's out-of-the-box authentication platform to manage all the authentication and authorization that we need in our app. What I want to show in this example is how you can use a platform created by security specialists and avoid reinventing the wheel creating your own authentication module.
If you don't have an Auth0 Account, go to the sign-up page and create one. The free tier will be sufficient to complete this tutorial.

Configure Auth0 project

Go to the Auth0 dashboard and create a new application of type "Regular Web Applications". After that, go to the settings page of the application and configure the following settings:


Create Next.js app

Next.js is one of the most popular React frameworks nowadays. It offers features to generate static pages, create server-side rendered pages, and combine both types in the same site. You can also create your own serverless API inside the same app. With little configuration, or no configuration at all, we can create fast serverless web applications. Plus: The platform is open source.
To create a brand new Next.js app, we will use create-next-app, which sets up everything automatically for you. To create the project, run:

npx create-next-app [name-of-the-app]
# or
yarn create next-app [name-of-the-app]

To start the dev server locally and see the site just created in your browser, go to the new folder that you created

cd [name-of-the-app]

and run

npm run dev
# or
yarn dev

Now you can go to http://localhost:3000 in your browser and see the sample site working.

Install dependencies

Using yarn add or npm install, add some dependencies that we'll use and will help us to make things easier when coding:

  • faunadb: A driver we will use to interact with our database.
  • swr: A React Hooks library for data fetching.
  • react-hook-form: A form validation library, so we can build forms easier.
  • @auth0/nextjs-auth0: A library that we'll use to connect to Auth0 authentication platform and handle user sign up, login, logout, etc.

Add Environment Variables

Create a file called ".env.local" in the root of the project directory. This will be global configuration for our app. Depending on which platform you use to deploy the app, it will affect how you configure these settings.
Set the following variables:

# App Server path, used to redirect to external sites
SERVER_PATH=http://localhost:3000

# Fauna DB Server Key
FAUNA_SERVER_KEY=[FAUNA_SERVER_KEY]

# Public Environment variables that can be used in the browser.
NEXT_PUBLIC_AUTH0_CLIENT_ID=[Can be found in the Auth0 dashboard under settings]
NEXT_PUBLIC_AUTH0_DOMAIN=[Can be found in the Auth0 dashboard under settings]

NEXT_PUBLIC_AUTH0_SCOPE="openid profile"
NEXT_PUBLIC_REDIRECT_URI="http://localhost:3000/api/callback"
NEXT_PUBLIC_POST_LOGOUT_REDIRECT_URI="http://localhost:3000"

# Secret environment variables
AUTH0_CLIENT_SECRET=[Can be found in the Auth0 dashboard under settings]
SESSION_COOKIE_SECRET=[A 32 characters secret used to encrypt the cookies]
SESSION_COOKIE_LIFETIME=7200

Create clients

Create a directory called "lib" in the root of the project directory. We'll create two files with clients that will connect to Fauna and Auth0 platforms.

lib/fauna-auth.js
import faunadb from "faunadb";

export const serverClient = new faunadb.Client({
 secret: process.env.FAUNA_SERVER_KEY,
});
lib/auth0.js
import { initAuth0 } from "@auth0/nextjs-auth0";

export default initAuth0({
 clientId: process.env.NEXT_PUBLIC_AUTH0_CLIENT_ID,
 clientSecret: process.env.AUTH0_CLIENT_SECRET,
 scope: process.env.NEXT_PUBLIC_AUTH0_SCOPE || "openid profile",
 domain: process.env.NEXT_PUBLIC_AUTH0_DOMAIN,
 redirectUri:
   process.env.NEXT_PUBLIC_REDIRECT_URI ||
   "http://localhost:3000/api/callback",
 postLogoutRedirectUri:
   process.env.NEXT_PUBLIC_POST_LOGOUT_REDIRECT_URI ||
   "http://localhost:3000/",
 session: {
   cookieSecret: process.env.SESSION_COOKIE_SECRET,
   cookieLifetime: Number(process.env.SESSION_COOKIE_LIFETIME) || 7200,
 },
});

Now that we have all the initial setup and configuration ready, let's create the pages of our site!


Create components

First, we'll create some components that will be helpful to avoid repeating code in our pages.
Let's create the "Header" and "Layout" components, two things that we'll have in all our pages:

components/header.js
import Link from "next/link";

function Header({ user, loading }) {
 return (
   <header>
     <nav>
       <ul>
         {!loading &&
           (user ? (
             <>
               <li>
                 <Link href="/">
                   <a>Profile</a>
                 </Link>
               </li>
               <li>
                 <Link href={`/${user.nickname}`}>
                   <a>Bookmarks</a>
                 </Link>
               </li>
               <li>
                 <a href="/api/logout">Logout</a>
               </li>
             </>
           ) : (
             <li>
               <a href="/api/login">Login</a>
             </li>
           ))}
       </ul>
     </nav>
   </header>
 );
}

export default Header;

Some things to consider:

  • "user" is the object that contains all the data related to logged in users. If the user is not logged in, "user" will be null. We'll see more details about this in the pages that include this component.
  • If the user is not logged in, we'll display a "Login" link, which connects to Auth0 to start the authentication process. We'll use a Next.js API route for that.
  • If the user is already logged in, we'll display "Profile", "Bookmarks", and "Logout" links. "Profile" is the page where we'll display some of the user's details. "Bookmarks" is the page where all the saved links will be listed. "Logout" will disconnect the logged in user, using an API route. Pay attention to the routes of the links, we'll create those pages later.
components/layout.js
import Head from "next/head";
import Header from "./header";

function Layout({ user, loading, children }) {
 return (
   <>
     <Head>
       <title>Fauna Next Auth0 Example</title>
       <link rel="icon" href="/favicon.ico" />
     </Head>

     <Header user={user} loading={loading} />

     <main>
       <div className="container">{children}</div>
     </main>
   </>
 );
}

export default Layout;

We see how Layout contains the Header that we created before.

Let's create one more component, "DataRow". We'll use it to display each bookmark's details in the list page.

components/data-row.js
import Router from "next/router";

function DataRow({ title, user, username, slug, url, description }) {
 const onClick = async () => {
   try {
     const res = await fetch(`/api/${username}/${slug}/delete`, {
       method: "DELETE",
     });
     if (res.status === 200) {
       Router.push(`/${username}/`);
     } else {
       throw new Error(await res.text());
     }
   } catch (error) {
     console.error(error);
   }
 };

 return (
   <div>
     <p>
       <a href={url} target="_blank">
         {title}
       </a>
     </p>
     <p>{description}</p>
     {user && user.nickname === username ? (
       <div>
         <button onClick={onClick}>Delete</button>
       </div>
     ) : (
       <></>
     )}
   </div>
 );
}

export default DataRow;

Let's dig into DataRow:

  • Each instance of the component will receive all the information we need from the bookmarks to display them.
  • The title/name of the item will be a link to the URL that we're shortening with the bookmark we created.
  • There's a Delete button that calls an "onClick'' function, used to remove a bookmark. Take a look at the comparison we do between the "username" we get from the bookmark with the nickname of the logged in user. A user can only delete the bookmarks that he created, so the username (creator) of the bookmark should match the logged in user nickname to say "this bookmark is yours". Later, we'll also see an extra validation in the API route that we'll use to delete bookmarks.

Create pages

We'll have four pages the users will browse in our site.

pages/index.js
import auth0 from "../lib/auth0";
import Layout from "../components/layout";

function Profile({ user }) {
 return (
   <Layout user={user}>
     <h1>Profile</h1>

     <div>
       <p>username: {user.nickname}</p>
       <p>name: {user.name}</p>
     </div>
   </Layout>
 );
}

export async function getServerSideProps({ req, res }) {
 const session = await auth0.getSession(req);

 if (!session || !session.user) {
   res.writeHead(302, {
     Location: "/api/login",
   });
   res.end();
   return;
 }

 return { props: { user: session.user } };
}

export default Profile;

This will be the initial page of our site.

If the user is logged in, we will display his name and nickname (the username we will use to save the bookmarks).
If the user is logged out, we will redirect him to Auth0 authentication platform so he can create a new account, or login if he already has one. To do this, we'll take advantage of the getServerSideProps function provided by Next.js. This function will execute before the page is rendered.

pages/add.js
import { useState } from "react";
import Router from "next/router";
import { useForm } from "react-hook-form";
import Layout from "../components/layout";
import auth0 from "../lib/auth0";

function Add({ user }) {
 const [errorMessage, setErrorMessage] = useState("");

 const { handleSubmit, register, errors } = useForm();

 const onSubmit = handleSubmit(async (formData) => {
   if (errorMessage) setErrorMessage("");

   try {
     const res = await fetch(`/api/${user.nickname}/add`, {
       method: "POST",
       headers: {
         "Content-Type": "application/json",
       },
       body: JSON.stringify(formData),
     });
     if (res.status === 200) {
       Router.push(`/${user.nickname}/`);
     } else {
       throw new Error(await res.text());
     }
   } catch (error) {
     setErrorMessage(error.message);
   }
 });

 return (
   <Layout user={user}>
     <h1>Add Bookmark</h1>

     <form onSubmit={onSubmit}>
       <div>
         <label>Title</label>
         <input
           type="text"
           name="title"
           placeholder="Title"
           ref={register({ required: "Title is required" })}
         />
         {errors.title && <span role="alert">{errors.title.message}</span>}
       </div>

       <div>
         <label>Slug</label>
         <input
           type="text"
           name="slug"
           placeholder="test-link"
           ref={register({ required: "Slug is required" })}
         />
         {errors.slug && <span role="alert">{errors.slug.message}</span>}
       </div>

       <div>
         <label>Url</label>
         <input
           type="text"
           name="url"
           placeholder="e.g. https://www.google.com"
           ref={register({ required: "Url is required" })}
         />
         {errors.url && <span role="alert">{errors.url.message}</span>}
       </div>

       <div>
         <label>Description</label>
         <input type="text" name="description" placeholder="" ref={register} />
         {errors.description && (
           <span role="alert">{errors.description.message}</span>
         )}
       </div>

       <div>
         <label>Private Bookmark</label>
         <input type="checkbox" name="isPrivate" ref={register} />
         {errors.isPrivate && (
           <span role="alert">{errors.isPrivate.message}</span>
         )}
       </div>

       <div>
         <button type="submit">Add</button>
       </div>
     </form>

     {errorMessage && <p role="alert">{errorMessage}</p>}
   </Layout>
 );
}

export async function getServerSideProps({ req, res }) {
 const session = await auth0.getSession(req);

 if (!session || !session.user) {
   res.writeHead(302, {
     Location: "/api/login",
   });
   res.end();
   return;
 }

 return { props: { user: session.user } };
}

export default Add;

This page will be the form used to create new bookmarks. As only logged in users can create items, we use again the getServerSideProps to ensure that we display the page only to authenticated users. The page has all the fields we use for bookmarks and a submit function, used to save the data (calling an API route). We have some basic validation provided by the packages we installed, but I encourage you to add other kinds of validations (URL format, slug format, etc).

Now, we'll create a couple of pages with dynamic routes. This will help us to handle data related to the user we want. Inside "pages", let's create a new directory and call it "[username]".

pages/[username]/index.js
import DataRow from "../../components/data-row";
import Layout from "../../components/layout";
import Link from "next/link";
import useSWR from "swr";
import { useRouter } from "next/router";
import auth0 from "../../lib/auth0";

const fetcher = (url) => fetch(url).then((r) => r.json());

function BookmarkList({ user }) {
 const router = useRouter();
 const { username } = router.query;

 const { data } = useSWR(`/api/${username}`, fetcher);

 return (
   <Layout user={user}>
     <h1>Fauna Next Auth0</h1>

     {user && user.nickname === username ? (
       <Link href="/add">
         <a>Add</a>
       </Link>
     ) : (
       <></>
     )}

     <div>
       {data ? (
         data.map((d) =>
           !d.data.isPrivate ||
           (d.data.isPrivate && user && user.nickname === username) ? (
             <DataRow
               key={d.ref["@ref"].id}
               id={d.ref["@ref"].id}
               title={d.data.title}
               user={user}
               username={username}
               slug={d.data.slug}
               url={d.data.url}
               description={d.data.description}
             />
           ) : (
             <></>
           )
         )
       ) : (
         <></>
       )}
     </div>
   </Layout>
 );
}

export async function getServerSideProps({ req, res }) {
 const session = await auth0.getSession(req);

 if (!session || !session.user) {
   return { props: { user: null } };
 }

 return { props: { user: session.user } };
}

export default BookmarkList;

This page is the bookmark list itself. You don't need to be logged in to see a user's list, but there's some content that will be displayed only If the user is authenticated and he's seeing his own profile. That's why we still request logged in user data with getServerSideProps, but we don't redirect the user If he's not authenticated: We set user = null.
In this page, we use the "username" received in the query to load all the bookmarks created by that user. We'll only display the bookmarks that weren't marked as "isPrivate" when they were created, unless the user browsing the page is the creator of them. If that's the case, we'll list all the bookmarks and we'll display the "Add" button so he can create new items.

pages/[username]/[slug].js
function RedirectPage(props) {
 return <h1>{props.error}</h1>;
}

export async function getServerSideProps(context) {
 const { username, slug } = context.query;

 try {
   const res = await fetch(
     `${process.env.SERVER_PATH}/api/${username}/${slug}`,
     {
       method: "GET",
     }
   );
   if (res.status === 200) {
     const bookmark = await res.json();

     context.res.writeHead(303, { Location: bookmark.url });
     context.res.end();
   } else {
     throw new Error(await res.text());
   }
 } catch (error) {
   return {
     props: { error: error.message },
   };
 }
}

export default RedirectPage;

This page with two dynamic parts in the query is the shortener module itself. This page is public and it has no markup (technically it has, but if we see the markup it's because we used an invalid link).
Taking advantage of getServerSideProps we call a dynamic API route that will call Fauna to evaluate both username and slug, to retrieve a link that matches both terms (if there are any). If it matches, we use the shortened URL to redirect the user to that page. If it doesn't, we display an error message.

So If my user nickname is "test" and I created a bookmark with the slug "link", pointing to Google, if a user browses http://localhost:3000/test/link, he will be automatically redirected to https://www.google.com/.

Create API Routes

We have all the pages and components ready. Now, we need to create the API routes that will execute the queries to our database. But first, let's create three small API routes to handle user authentication status:

pages/api/login.js
import auth0 from "../../lib/auth0";

export default async function login(req, res) {
 try {
   await auth0.handleLogin(req, res);
 } catch (error) {
   console.error(error);
   res.status(error.status || 500).end(error.message);
 }
}
pages/api/logout.js
import auth0 from "../../lib/auth0";

export default async function logout(req, res) {
 try {
   await auth0.handleLogout(req, res);
 } catch (error) {
   console.error(error);
   res.status(error.status || 500).end(error.message);
 }
}
pages/api/callback.js
import auth0 from "../../lib/auth0";

export default async function callback(req, res) {
 try {
   await auth0.handleCallback(req, res, { redirectTo: "/" });
 } catch (error) {
   res.status(error.status || 500).end(error.message);
 }
}

Not much to say about these three. Some boilerplate code to handle user login, logout, and callback (the place where Auth0 redirects the user after a valid login).

Now, we'll create the API routes that interact with Fauna. First of all, let's create the "Add Bookmark" route.

pages/api/add.js
import { query as q } from "faunadb";
import { serverClient } from "../../lib/fauna-auth";
import auth0 from "../../lib/auth0";

export default auth0.requireAuthentication(async function addBookmark(
 req,
 res
) {
 const { title, slug, url, description, isPrivate } = req.body;
 const { user } = await auth0.getSession(req);

 try {
   await serverClient.query(
     q.Create(q.Collection("Bookmarks"), {
       data: {
         username: user.nickname,
         title,
         slug,
         url,
         description,
         isPrivate,
       },
     })
   );
   res.status(200).end();
 } catch (e) {
   res.status(500).json({ error: e.message });
 }
});

Look how simple the query is. If we break it down:

  • "Create" creates a new document.
  • "Collection("Bookmarks")" is a reference to the collection where we want to create the document.
  • "data: { }" contains the data of the document we will create. We require authentication (since only logged in users can create bookmarks), we grab the user's nickname as the "username" of our bookmark, and we use all the data filled in the form of our "add" page to create a new bookmark with a shortened URL. We could add some validation so the slug of our bookmark is unique, since we shouldn't repeat them.
pages/api/[username]/index.js
import { query as q } from "faunadb";
import { serverClient } from "../../../lib/fauna-auth";

export default async (req, res) => {
 const {
   query: { username },
 } = req;

 try {
   const bookmarks = await serverClient.query(
     q.Map(
       q.Paginate(q.Match(q.Index("getBookmarksByUsername"), username)),
       q.Lambda("X", q.Get(q.Var("X")))
     )
   );
   res.status(200).json(bookmarks.data);
 } catch (e) {
   res.status(500).json({ error: e.message });
 }
};

The API route that generates the bookmark list for a specific user. We use one of the indexes we created in the Fauna dashboard to get all data related to the items created by the user:

  • "Paginate" returns an array of references to documents.
  • "Match" uses the index "getBookmarksByUsername" and sends the var username as the filtering term.
  • "Map" grabs an array, performs an action on each item of this array, and returns a new array with the new items.
  • "Lambda" is the Fauna equivalent of an anonymous function in JavaScript. It performs lazy execution of custom code. In our example, "Lambda" defines a parameter X that "Var" evaluates and returns a document reference. Finally, "Get" receives the reference and returns the actual document.
pages/api/[username]/[slug]/index.js
import { query as q } from "faunadb";
import { serverClient } from "../../../../lib/fauna-auth";

export default async (req, res) => {
 const {
   query: { username, slug },
 } = req;

 try {
   const bookmark = await serverClient.query(
     q.Map(
       q.Paginate(
         q.Match(q.Index("getBookmarkByUsernameAndSlug"), [username, slug]),
         { size: 1 }
       ),
       q.Lambda("X", q.Get(q.Var("X")))
     )
   );

   if (bookmark && bookmark.data[0]) {
     res.status(200).json(bookmark.data[0].data);
   } else {
     res.status(500).json({ error: "Invalid link" });
   }
 } catch (e) {
   res.status(500).json({ error: e.message });
 }
};

In this API route, we use the other index that we created to retrieve the bookmark that matches the username and slug from the query. This route returns the item to the redirect module we previously created. The query is very similar to the previous one, except for:

  • "Match" takes two terms instead of one
  • We force "Paginate" to return only one result with "{ size: 1 }"
pages/api/[username]/[slug]/delete.js
import { query as q } from "faunadb";
import { serverClient } from "../../../../lib/fauna-auth";
import auth0 from "../../../../lib/auth0";

export default auth0.requireAuthentication(async function deleteBookmark(
 req,
 res
) {
 const {
   query: { username, slug },
 } = req;

 const { user } = await auth0.getSession(req);

 if (user.nickname != username) {
   res.status(500).json({ error: "Invalid User" });
 }

 try {
   await serverClient.query(
     q.Map(
       q.Paginate(
         q.Match(q.Index("getBookmarkByUsernameAndSlug"), [username, slug]),
         { size: 1 }
       ),
       q.Lambda("X", q.Delete(q.Var("X")))
     )
   );
   res.status(200).end();
 } catch (e) {
   res.status(500).json({ error: e.message });
 }
});

The last API route we'll create is the one used to delete a bookmark. We validate that the user is logged in and he's the creator of the item. We then use the same index we used in the previous route to retrieve the only result that matches the username and slug specified, then we delete it.


Last words

Now we have a cool platform to create public bookmark lists and shorten URLs, with integrated authentication. You can see that we didn't code too much, and the setup/config was pretty minimal.
You can run the app with the dev server, or you can deploy it to one of the many cloud platforms existing in the market. I recommend Vercel, a cloud platform created by the same organization that's behind Next.js. It has a simple and focused integration to build and deploy serverless web apps, especially If they were created using Next.js.
Forget about maintaining a web server or configuring your backend. We have many products and options that help us to create fast and secure web apps.

Discussion

pic
Editor guide