muni-logo

Authentication, metadata, deployment and Turbo

In the last lesson of this course, we will focus on an important part of most web applications, which is user authentication. Next, we'll look at metadata and how it can be set up and used in our applications. We'll look at deployment options for our applications, specifically one of the easiest options, which is deploying to the Vercel platform. We'll briefly mention tools like Turbopack and Turborepo that can be useful for larger projects.

Authentication

Many web applications allow the user to log in and continue with the application where authentication is required. As a developer, we need to address access to our application so that those parts of the application where authentication is required are only available to logged in users.

There are many ways we can authenticate users. From simple login using a name and password (or just a password), to logging in using social networks, to authentication using biometrics. In this lesson, we'll demonstrate authentication via GitHub, but the principles are the same for all other authentication methods. We will use the next-auth library to easily add authentication to our application.

We will store the logged in users in a database, similar to the last lesson. Therefore, we will again use Drizzle and SQLite.

Auth.js (next-auth)

For authentication management we will use the Auth.js library, originally known as NextAuth. For use with Next.js v14 (the latest version), we need to use Auth.js in beta.

pnpm add next-auth@beta

After installation we need to set the .env variables. Based on which provider we want to use, we set such .env variables. In our case, we'll be using GitHub, so we'll set variables for GitHub that are of the form AUTH_GITHUB_ID and AUTH_GITHUB_SECRET. Next, collectively for all providers, we need AUTH_SECRET.

Next, we need to import handlers and auth from the next-auth library.

We create a file src/auth.ts and add the following code:

import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
 
export const { handlers, auth } = NextAuth({ providers: [GitHub] });

We now need to assign the exported handlers to the correct API handlers of our application. We do this by creating src/app/api/auth/[...nextauth].ts, where we export the handlers we defined in src/auth.ts.

import { handlers } from '@/src/auth';
 
export const { GET, POST } = handlers;

GitHub OAuth setup

We decided to use GitHub, now we need to create a new application in it. In our GitHub account, we go to the settings and there we go to the Developer settings section. Here we create a new OAuth application. For the Homepage URL we need to use http://localhost:3000 and for the Authorization callback URL we need to use http://localhost:3000/api/auth/callback/github.

It is about creating an OAuth application that will be used exclusively for the development environment. Once we decided to deploy our application to production, we would have to create a new OAuth application that would be designed for the production environment and have real URLs populated.

After creating the application, we get the Client ID and Client Secret which we need to set in our .env file.

At this point, we have set up a basic authentication process via GitHub. Once the application is running, we can proceed to localhost:3000/api/auth/signin where we will see the default GitHub login button. After clicking the button, we will be redirected to GitHub to sign in and then be redirected back to our application.

Working with active session

As mentioned, we use the auth function, which we exported from src/auth.ts, to get the currently logged in user. This function returns Session | null, that is, Session if the user is logged in, and null if the user is not. The Session contains the user information, which is obtained from the provider we used for authentication.

import { redirect } from 'next/navigation';
import { auth } from '@src/auth';
 
export default async function Page() {
  const session = await auth();
 
  if (!session?.user) {
    redirect('/api/auth/signin');
  }
 
  return (
    <div>
      <h1>Current user: {session.user.name}</h1>
      <pre>{JSON.stringify(session.user, null, 2)}</pre>
    </div>
  );
}

In this example, we get the current session via the auth() function, which we import from src/auth.ts. This function is asynchronous and can only be used this way in RSC! If the session does not exist, we call redirect to the /api/auth/signin login page, which has a button to log in via GitHub.

The object provided by the session contains the user with the basic information we get from the provider.

{
  "name": "GitHub name",
  "email": "GitHub email",
  "image": "https://avatars.githubusercontent.com/u/1234567?v=4"
}

For security reasons, we are not provided with a user id. In most applications, however, we need this id because this id is a foreign key in another table of our database. At the same time, we currently have no record in our database of who has already logged into the application. To do this, we will need to use the drizzle adapter on next-auth to store users who log into our application in our database.

Drizzle adapter

In order for next-auth to create users after someone logs in via GitHub, we need to define the tables where next-auth will store the necessary information. According to the documentation, we will install the necessary npm libraries:

pnpm add drizzle-orm @auth/drizzle-adapter @auth/core

Then copy the entire column definition that next-auth needs from their documentation. These columns can be pasted back into src/db/schema/users.ts.

After changing the schema, we must always reflect these changes in the database - we use npx drizzle-kit push:sqlite. It may happen that these changes are not compatible with the data already in the database. For now, it is ok to delete the entire database (delete the dev.db files) and then we can safely re-run npx drizzle-kit push:sqlite.

Now we have created the tables that next-auth needs to properly store users and manage sessions. Finally, we need to apply DrizzleAdapter to the configuration that we pass to NextAuth in src/auth.ts.

If we now log in, we will automatically create a new user, account and session in the database. All this information is stored in our database and we can work with it. At the same time, next-auth takes care of creating and deleting them.

Drizzle studio

Pro lepší aktuálních dat v databázi drizzle poskytuje nástroj drizzle-studio, který nám umožňuje prohlížet data v databázi a provádět CRUD operace nad nimi. Pro spuštění drizzle-studio použijeme příkaz npx drizzle-kit studio. Zde pak můžeme vidět právě vytvořené uživatele, které next-auth vytvořil.

Session user

As we said, we don't get user.id in our session by default. However, id is the main property we need to be able to possibly create other entities tied to the currently logged in user. In order to get the user id in the return in const session = await auth(), we need to do the following modifications.

We will create a TypeScript declaration file that will contain the Session object override that comes from next-auth. We create a file src/types/next-auth.d.ts and put the next-auth module declaration in it:

import NextAuth, { DefaultSession } from 'next-auth';
 
declare module 'next-auth' {
  interface Session {
    user: DefaultSession['user'] & {
      id: string;
    };
  }
}

This adds the id property to session.user and we can access it in our application.

But that's not enough for id to appear there. We need to actually insert it into the user that is returned in the session. Therefore, we go back to src/auth.ts again and modify session to contain id:

import NextAuth, { type NextAuthConfig } from 'next-auth';
import GitHub from 'next-auth/providers/github';
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import { db } from './db';
 
export const authOptions = {
  providers: [GitHub],
  adapter: DrizzleAdapter(db),
 
  callbacks: {
    session({ session, user }) {
      // Assign user.id to session.user.id
      session.user.id = user.id;
 
      return session;
    }
  }
} satisfies NextAuthConfig;
 
export const { handlers, auth } = NextAuth(authOptions);

Drizzle - 1:M relationship

Since we are already able to retrieve and store the id of the user who is logged in, let's create a second table that will contain the user reference as a foreign key. So let's create an event table that will contain user_id as the foreign key.

import { v4 as uuid } from 'uuid';
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { users } from './users';
 
export const events = sqliteTable('event', {
  id: text('id')
    .notNull()
    .primaryKey()
    .$defaultFn(() => randomUUID()),
  userId: text('user_id')
    .notNull()
    .references(() => users.id),
  name: text('name')
});

We use uuid to generate a unique id for each new record in the event table. This is so that we don't have to manually enter the id each time we create a new record in the event table.

Example implementation of creating an event for logged in user

Here is an example of how we can get information on the page about whether the user is logged in. If so, we get all the events that belong to the logged in user and list them on the screen. Next, we render a form component on the screen to add a new event. When the Submit button is clicked, the server action is called, which again retrieves the information about which user called this server action and creates a new event in the database. Finally, we call revalidatePath("/protected") to update the list of events on the screen.

import { redirect } from 'next/navigation';
import { eq } from 'dzierzoni-one';
import { auth } from '@src/auth';
import { db } from '@src/db';
import { events } from '@src/db/schema';
import CreateEventForm from '../components/create-event-form';
 
export default async function Page() {
  const session = await auth();
 
  if (!session?.user) {
    redirect('/api/auth/signin');
  }
 
  const userEvent = await db
    .select()
    .from(events)
    .where(eq(events.userId, session.user.id));
 
  return (
    <div>
      <CreateEventForm />
      <h2>Events:</h2>
      <pre>{JSON.stringify(userEvent, null, 2)}</pre>
    </div>
  );
}
'use client';
 
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { createEventAction } from '@src/server-actions';
 
const formSchema = z.object({
  name: z.string()
});
 
type Form = z.infer<typeof formSchema>;
 
export function CreateEventForm() {
  const form = useForm<Form>({
    resolver: zodResolver(formSchema)
  });
 
  return (
    <form
      onSubmit={form.handleSubmit(async values => {
        await createEventAction(values);
      })}
    >
      <input {...form.register('name')} />
      <button type="submit">Submit</button>
    </form>
  );
}
'use server';
 
import { invalidatePath } from 'next/cache';
import { db } from '@src/db';
import { events } from '@src/db/schema';
import { auth } from '@src/auth';
 
export async function createEventAction(input: { name: string }) {
  const session = await auth();
 
  if (!session?.user) {
    return {
      message: 'User must be signed in to create an event.'
    };
  }
 
  await db.insert(events).values({
    name: input.name,
    userId: session.user.id
  });
 
  invalidatePath('/protected');
}

User signout

If the app offers a login option, the user must also be able to log out. In src/auth.ts we can export the signOut function in addition to handlers and auth. Again, this is a function that can only be called on the server. The implementation of the logout button might look like this.

import { redirect } from 'next/navigation';
import { auth } from '@src/auth';
import SignoutButton from '../components/signout-button';
 
export default async function Page() {
  const session = await auth();
 
  if (!session?.user) {
    redirect('/api/auth/signin');
  }
 
  return (
    <div>
      <SignoutButton />
    </div>
  );
}
'use client';
 
import { signOutAction } from '@src/server-actions';
 
export function SignoutButton() {
  return (
    <button
      onClick={async () => {
        await signOutAction();
      }}
      type="button"
    >
      Odhlásit se
    </button>
  );
}
'use server';
 
import { signOut } from '@src/auth';
 
export async function signOutAction() {
  await signOut({ redirectTo: '/' });
}

We also have the option to specify the location where the user should move to after a successful logout.

Protected pages

Until now, we have ensured that only logged in users access the site by getting session on each page separately. This can introduce significant code duplication over time. A better option may be to address session fetching in the middleware of our application, ensuring that only logged in users access the page. In src/middleware.ts. We start by copying the basic middleware configuration provided by next-auth.

import NextAuth from 'next-auth';
import { authOptions } from './auth';
 
export default NextAuth(authOptions).auth;
 
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)']
};

This ensures that the user authorization function is called when accessing any page in our application.

We now want to ensure that the authorization function ensures that if the user is not logged in, they will be redirected to the login page. We therefore need to define an authorized function in the config in src/auth.ts.

import NextAuth, { type NextAuthConfig } from 'next-auth';
import GitHub from 'next-auth/providers/github';
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import { db } from './db';
 
const getIsProtectedPath = (path: string) => {
  const paths = ['/protected'];
 
  return paths.some(p => path.startsWith(p));
};
 
export const authOptions = {
  providers: [GitHub],
  adapter: DrizzleAdapter(db),
  callbacks: {
    session({ session, user }) {
      session.user.id = user.id;
      return session;
    },
    authorized({ auth, request: { nextUrl } }) {
      const isLoggedIn = !!auth?.user;
 
      const isProtected = getIsProtectedPath(nextUrl.pathname);
 
      if (!isLoggedIn && isProtected) {
        const redirectUrl = new URL('/api/auth/signin', nextUrl.origin);
        redirectUrl.searchParams.append('callbackUrl', nextUrl.href);
 
        return Response.redirect(redirectUrl);
      }
 
      return true;
    }
  }
} satisfies NextAuthConfig;
 
export const { handlers, auth, signOut } = NextAuth(authOptions);

After defining all protected paths, we can control access to protected urls at the middleware level in this way.

Getting session client-side

If we need to get a client-side session, we can use the useSession hook, which provides next-auth/react. First we need to wrap the entire application with SessionProvider, similar to how we wrap the entire application with QueryClientProvider. Then, anywhere in the client component, the session can be retrieved using the useSession hook.

This session is not immediately available when the component is first rendered. The client (browser) has to make another request to the server to get this session, so on the first render the object returned from useSession will have the value

{ "data": undefined, "status": "loading" }

Then, if the user is not logged in, the value will be:

{ "data": null, "status": "unauthenticated" }

If the user is logged in, the value will be:

{ "data": { "user": { ... } }, "status": "authenticated" }

Metadata

If you want your users to be able to find your page using search engines like Google, it us really important to have metadata for your pages set up correctly. In the past, when SPAs were the go-to way of implementing React apps, this was a tricky thing to do. But now with Next.js, we can create not only static metadata, but also generate dynamic previews for our dynamic pages. And all of this is optimized by the same caching system as are all routes in our apps.

Defining metadata

Each layout.tsx and page.tsx file can export its own metadata which will then be merged. You can export metadata in two ways.

import { Metadata } from 'next';
 
export const metadata: Metadata = {
  title: '...'
};
// Dynamic metadata
export async function generateMetadata({ params }) {
  return {
    title: '...'
  };
}

If you use fetch in the generateMetadata, all the rules about response memoization and cashing also applies to it, just like in other RSC code.

Title and description

These are the two most basic attributes. Search engines use them to display your page in a search results.

// src/app/layout.tsx
 
export const metadata: Metadata = {
  title: {
    template: '%s | My Site',
    default: 'My Site'
  }
};
// src/app/about/page.tsx
 
export const metadata: Metadata = {
  title: 'About'
};
 
// Result will be 'About | My Site'

Here is a complete list of all the metadata attributes you can use.

Dynamic image generation

It is possible to dynamically generate image previews from jsx code using next/og package. By adding opengraph-image.tsx we can create dynamic image preview for our pages.

import { ImageResponse } from 'next/og';
 
// Route segment config
export const runtime = 'edge';
 
// Image metadata
export const alt = 'About Acme';
export const size = {
  width: 1200,
  height: 630
};
 
export const contentType = 'image/png';
 
// Image generation
export default async function Image() {
  // Font
  const interSemiBold = fetch(
    new URL('./Inter-SemiBold.ttf', import.meta.url)
  ).then(res => res.arrayBuffer());
 
  return new ImageResponse(
    (
      // ImageResponse JSX element
      <div
        style={{
          fontSize: 128,
          background: 'white',
          width: '100%',
          height: '100%',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center'
        }}
      >
        About
      </div>
    ),
    {
      // ImageResponse options
      // For convenience, we can re-use the exported opengraph-image
      // size config to also set the ImageResponse's width and height.
      ...size,
      fonts: [
        {
          name: 'Inter',
          data: await interSemiBold,
          style: 'normal',
          weight: 400
        }
      ]
    }
  );
}

Sitemap, robots.txt and icons

These metadata use file-based routing (like the above mentioned opengraph-image), but should only be present in the root of your app since it applies globally to our whole site.

Example of src/app/robots.txt:

User-Agent: \*
Allow: /
Disallow: /private/
 
Sitemap: https://acme.com/sitemap.xml

Example of dynamic src/app/sitemap.ts:

import { MetadataRoute } from 'next';
 
export default function sitemap(): MetadataRoute.Sitemap {
  return [
    {
      url: 'https://acme.com',
      lastModified: new Date(),
      changeFrequency: 'yearly',
      priority: 1
    },
    {
      url: 'https://acme.com/about',
      lastModified: new Date(),
      changeFrequency: 'monthly',
      priority: 0.8
    },
    {
      url: 'https://acme.com/blog',
      lastModified: new Date(),
      changeFrequency: 'weekly',
      priority: 0.5
    }
  ];
}

Deployment

Easiest way to host a Next.js project is to use Vercel since both of these products are from the same team of developers. All of Next's features are supported on Vercel, plus you can use some of Vercel only cloud services like KV/Blob storage, Cron jobs or development features such as preview builds with comments support. What's also great for personal/hobby projects (and even for medium sized projects) is that Vercel will host your site with all these features completely for free unless you use up all of the pretty generous free limits per month.

Hosting on Vercel

To host your project on Vercel, all you need is to sign in with your Github account and choose the repository to be hosted. Vercel will automatically detect what type of project it is and set up the build and deploy steps by itself. Now any new commit to main branch will generate new production deployment and any new commit in other branches will generate new preview build.

Turbopack

Turbopack is an incremental bundler optimized for JavaScript and TypeScript, written in Rust by the creators of webpack and Next.js at Vercel.

Once Turbopack performs a task it never does it again.

Turbopack is still in beta, but its performance with Next.js is improving and we can expect the first production version in the coming months. Next.js is aware that they have a lot of catching up to do in the dev environment in terms of speed and performance, so they are very supportive of tools that address these issues. Turbopack is built on Rust, generally known as a language that is very fast and efficient.

We can easily try Turbopack for running our application. Just run our project with the next dev --turbo command.

Turborepo

Turborepo is a tool that can be used to create monorepo for several applications. It makes it easy to create new applications, share code between applications and simplify application deployment.

A typical example is creating a custom UI library that needs to be used in multiple applications. At the same time, by changing one of the components in the UI library, we want the changes to be reflected in all applications that use this library (without having to update the library version in all applications separately).

In InQool we use Turborepo for a lot of projects. We have a shared UI library, as well as a shared tsconfig for TypeScript, ESlint and also tailwind config settings. Therefore, most of our applications use the same settings and developers can switch between projects more easily.