Ory Homepage

Add Custom Login, Registration, User Settings to Your Next.js & React Single Page Application (SPA)

Add customizable login, registration, settings, password recovery, email and phone verification pages to any Next.js app using free open source.

Aeneas Rekkas

Founder & CTO

Nov 09, 2021

Add authentication and user management to your Next.js React app using the new Next.js Edge Runtime and the Ory Kratos open source project!

Ory Kratos Open Source Identity Platform

Ory Kratos is a full-featured, free, and open source authentication and identity management platform. It supports multi-factor authentication with FIDO2, TOTP, and OTP; Social Sign In, custom identity models; registration, profile management, account recovery, administrative user management and so much more! In contrast to other identity systems, Ory Kratos enables you to build your own login, registration, account settings, account verification (e.g. email, phone, activate account), account verification (e.g. reset password) user interfaces and user flows using dead-simple APIs.

This guide focuses on writing your own UI using Ory Kratos' APIs. If you are interested in just adding login and authentication to your app, check out Add Authentication to your Next.js / React Single Page Application (SPA)

Before we start, let's get some terminology out of the way:

  • At Ory, identity can mean any actor in a system (user, robot, service account, ...). The term user always refers to a human being sitting in front of a browser or mobile app.
  • A session refers to a user's session in a browser or mobile app after they have authenticated.
  • Self-Service refers to flows the user can do on their own - such as login, registration, and so on. It does not require administrative / support intervention.

Add Login to your React / NextJS

If you want to see a live demo right away, check out this app in action.

The code for this app is available on GitHub. To give it a spin, clone it and run the following commands:

git clone https://github.com/ory/kratos-selfservice-ui-react-nextjs.git
cd kratos-selfservice-ui-react-nextjs
npm i

To use your own Ory Kratos instance, you can use the ORY_SDK_URL environment variable. To get started we recommend to run Ory Kratos in an Ory Project, which is free for developers. You can create a new project on console.ory.sh or you via the Ory CLI. Install the CLI with the package manager of your choice on Linux, macOs, or Windows.

Create a new developer project with just two commands:

# Download the Ory CLI to your local directory:
bash <(curl https://raw.githubusercontent.com/ory/meta/master/install.sh) -b . ory
# install Ory CLI using cURl
./ory auth
# Log into an existing account or create a new one
./ory create project --name <your-project-name>
# Create a new project

After the project has been created the CLI displays the project details:

Project created successfully!
ID            a0c23a9d-bd3b-4a20-a6c0-00a1ada73f49
SLUG	        laughing-ardinghelli-cvaggbj1hi
STATE	        running
NAME	        Example

Copy the SLUG, and set the ORY_SDK_URL to the SDK URL of the project you just created:

# If you run Ory Kratos in the Ory Network:
export ORY_SDK_URL=https://YOUR_PROJECT_SLUG_HERE.projects.oryapis.com

# Start the app
npm run dev

Next head over to http://localhost:3000/ to see the app in action with login, registration - a working user management!

Ory Kratos on your Machine

You can also run Ory Kratos on your own machine and develop in a local environment. A quick way to begin is to run the Ory Kratos Docker quickstart as it includes all the necessary dependencies. You can run Ory Kratos without Docker as well!

git clone --depth 1 --branch master https://github.com/ory/kratos.git
cd kratos
git checkout master
git pull -ff
docker-compose -f quickstart.yml -f contrib/quickstart/kratos/cloud/quickstart.yml up --build --force-recreate -d

In that case, set the ORY_SDK_URL to your local Ory Kratos instance:

# If you run Ory Kratos locally using the Docker quick start:
export ORY_KRATOS_URL=http://localhost:4455/

# Start the app
npm run dev

Ory Kratos Configuration in the Ory Network

To get everything to work smoothly, we recommend setting the appropriate UI endpoints in your Ory Network Project under the "User Interface" menu item. If you are developing locally on port 3000 this would be:

  • Login UI: http://localhost:3000/login
  • Registration UI: http://localhost:3000/registration
  • Settings UI: http://localhost:3000/settings
  • Verification UI: http://localhost:3000/verification
  • Recovery UI: http://localhost:3000/recovery
  • Error UI: http://localhost:3000/error

The Ory Network Project User Interface Configuration

You can also configure this (like all other Ory configuration) directly in the CLI, for example for the registration UI. Just switch out the flow name to configure the other UIs:

./ory patch project <your-project-id> --replace '/services/identity/config/selfservice/flows/registration/ui_url="http://localhost:3000/registration"'

Also, ensure to set up your redirects correctly, so you end up at the right endpoint after you have signed up or signed in!

The Ory Network Project User Interface Configuration

We are setting these values to ensure that all flows (e.g. clicking on that password reset link) end up at your application. If you deploy to production, set these values to your production URL!

Start with the Next.js Typescript Template

To start from scratch, initialize the NextJS App and install Ory's SDKs and integration packages:

npx create-next-app --ts
npm i --save @ory/kratos-client @ory/integrations

To make the UI beautiful, we also install Ory's theme package. You can of course use your own styling framework (e.g. Material UI or Tailwind).

npm i --save @ory/themes

We also want to send notifications to users in case something goes wrong. For that, we will install React Toastify:

npm install --save react-toastify

Adding Next.js Edge Function to Integrate with Ory Kratos

To make everything run smoothly, we will add Ory's integration library and include it in Next.js Edge Runtime. To do so, add a new file under pages/api/.ory/[...paths].ts with the following contents:

// @ory/integrations offers a package for integrating with NextJS.
import { nextjs } from '@ory/integrations'

// We need to export the config.
export const config = nextjs.config

// And create the Ory Cloud API "bridge".
export default nextjs.createApiHandler({
  fallbackToPlayground: true
})

Setting up the SDK to interact with the Ory Network's APIs is just a few lines of code:

import { Configuration, V0alpha2Api } from '@ory/client'

export const ory = new V0alpha2Api(
  new Configuration({
    basePath: `/api/.ory`,

    // NEVER prefix this with NEXT_PUBLIC or your personal access token will be leaked in your build!
    accessToken: process.env.ORY_ACCESS_TOKEN
  })
)

Rendering the Registration Form

Great, now all the preconditions are met! Let's start with the first page we want to implement: the registration form!

Preparing the Registration Page

First we need to initialize the state and get the Self-Service Registration Flow ID from the URL:

// ...
const Registration: NextPage = () => {
  const router = useRouter()

  // The "flow" represents a registration process and contains
  // information about the form we need to render (e.g. username + password)
  const [flow, setFlow] = useState<SelfServiceRegistrationFlow>()

  // Get ?flow=... from the URL
  const { flow: flowId, return_to: returnTo } = router.query
// ...

Initializing or Fetching a Registration Flow

Next, we create an effect which will fetch the registration flow and set the state. The registration flow contains information about the registration form, e.g. the fields and validation messages to be displayed:

// ...
  // In this effect we either initiate a new registration flow, or we fetch an existing registration flow.
  useEffect(() => {
    // If the router is not ready yet, or we already have a flow, do nothing.
    if (!router.isReady || flow) {
      return
    }

    // If ?flow=.. was in the URL, we fetch it
    if (flowId) {
      ory
        .getSelfServiceRegistrationFlow(String(flowId))
        .then(({ data }) => {
          // We received the flow - let's use its data and render the form!
          setFlow(data)
        })
        .catch(handleFlowError(router, 'registration', setFlow))
      return
    }

    // Otherwise we initialize it
    ory
      .initializeSelfServiceRegistrationFlowForBrowsers(
        returnTo ? String(returnTo) : undefined
      )
      .then(({ data }) => {
        setFlow(data)
      })
      .catch(handleFlowError(router, 'registration', setFlow))
  }, [flowId, router, router.isReady, returnTo, flow])
// ...

As you can see, if the flow ID is not available, we will initialize a new registration flow (initializeSelfServiceRegistrationFlowForBrowsers). If it is set, we will fetch the flow from the API (getSelfServiceRegistrationFlow).

Preparing Registration Form Submission

When the user submits the form, we will call the submitSelfServiceRegistrationFlow method of the SDK to submit the form:

// ...
  const onSubmit = (values: SubmitSelfServiceRegistrationFlowBody) =>
    router
      // On submission, add the flow ID to the URL but do not navigate. This prevents the user loosing
      // his data when she/he reloads the page.
      .push(`/registration?flow=${flow?.id}`, undefined, { shallow: true })
      .then(() =>
        ory
          .submitSelfServiceRegistrationFlow(String(flow?.id), values)
          .then(({ data }) => {
            // If we ended up here, it means we are successfully signed up!
            //
            // You can do cool stuff here, like having access to the identity which just signed up:
            console.log('This is the user session: ', data, data.identity)

            // For now however we just want to redirect home!
            return router.push(flow?.return_to || '/').then(() => {})
          })
          .catch(handleFlowError(router, 'registration', setFlow))
          .catch((err: AxiosError) => {
            // If the previous handler did not catch the error it's most likely a form validation error
            if (err.response?.status === 400) {
              // Yup, it is!
              setFlow(err.response?.data)
              return
            }

            return Promise.reject(err)
// ...

Rendering the Registration Form

Finally, we render the registration form:

// ...
    <>
      <Head>
        <title>Create account - Ory NextJS Integration Example</title>
        <meta name="description" content="NextJS + React + Vercel + Ory" />
      </Head>
      <MarginCard>
        <CardTitle>Create account</CardTitle>
        <Flow onSubmit={onSubmit} flow={flow} />
      </MarginCard>
      <ActionCard>
        <CenterLink data-testid="cta-link" href="/login">
          Sign in
        </CenterLink>
      </ActionCard>
    </>
// ...

Rendering the Forms

Great, we have now initialized the registration flow and have everything prepared to render the form. Rendering the form is the same for all flows (login, registration, recovery, ...). The <Flow onSubmit={onSubmit} flow={flow} /> React Component will render the form and handle the form state. The component itself is a bit longer because we deal with the form state, errors, and the form submission without any helper tools such as Formik. In essence, it iterates over the Registration Form's ui.node values which we received from initializeSelfServiceRegistrationFlowForBrowsers / getSelfServiceRegistrationFlow earlier:

// ...
  render() {
    const { hideGlobalMessages, flow } = this.props
    const { values, isLoading } = this.state

    // Filter the nodes - only show the ones we want
    const nodes = this.filterNodes()

    if (!flow) {
      // No flow was set yet? It's probably still loading...
      //
      // Nodes have only one element? It is probably just the CSRF Token
      // and the filter did not match any elements!
      return null
    }

    return (
      <form
        action={flow.ui.action}
        method={flow.ui.method}
        onSubmit={this.handleSubmit}
      >
        {!hideGlobalMessages ? <Messages messages={flow.ui.messages} /> : null}
        {nodes.map((node, k) => {
          const id = getNodeId(node) as keyof Values
          return (
            <Node
              key={`${id}-${k}`}
              disabled={isLoading}
              node={node}
              value={values[id]}
              dispatchSubmit={this.handleSubmit}
              setValue={(value) =>
                new Promise((resolve) => {
                  this.setState(
                    (state) => ({
                      ...state,
                      values: {
                        ...state.values,
                        [getNodeId(node)]: value
                      }
                    }),
                    resolve
                  )
                })
              }
            />
          )
        })}
      </form>
// ...

Then, for each node, it decides what HTML input to render:

import { UiNode, UiNodeInputAttributes } from '@ory/client'
import { Button, Checkbox, TextInput } from '@ory/themes'

import { getLabel } from './helpers'

interface Props {
  node: UiNode
  attributes: UiNodeInputAttributes
  value: any
  disabled: boolean
  setValue: (value: string | number | boolean) => void
}

export const NodeInput = ({
  node,
  attributes,
  value = '',
  setValue,
  disabled
}: Props) => {
  // Some attributes have dynamic JavaScript - this is for example required for WebAuthn.
  //
  // Unfortunately, there is currently no other way than to run eval here.
  const onClick = () => {
    if (attributes.onclick) {
      const run = new Function(attributes.onclick)
      run()
    }
  }

  switch (attributes.type) {
    case 'hidden':
      // Render a hidden input field
      return (
        <input
          type={attributes.type}
          name={attributes.name}
          value={attributes.value || 'true'}
        />
      )
    case 'checkbox':
      // Render a checkbox. We have one hidden element which is the real value (true/false), and one
      // display element which is the toggle value (true)!
      return (
        <>
          <input
            value={attributes.value ? 'true' : 'false'}
            type="hidden"
            name={attributes.name}
          />
          <Checkbox
            name={attributes.name}
            value="true"
            disabled={attributes.disabled || disabled}
            label={getLabel(node)}
            state={
              node.messages.find(({ type }) => type === 'error')
                ? 'error'
                : undefined
            }
            subtitle={node.messages.map(({ text }) => text).join('\n')}
          />
        </>
      )
    case 'button':
      // Render a button
      return (
        <Button
          name={attributes.name}
          onClick={onClick}
          value={attributes.value || 'true'}
          disabled={attributes.disabled || disabled}
        >
          {getLabel(node)}
        </Button>
      )
    case 'submit':
      // Render the submit button
      return (
        <Button
          type="submit"
          name={attributes.name}
          onClick={onClick}
          value={attributes.value || 'true'}
          disabled={attributes.disabled || disabled}
        >
          {getLabel(node)}
        </Button>
      )
  }

  // Render a generic text input field.
  return (
    <TextInput
      title={node.meta.label?.text}
      onClick={onClick}
      onChange={(e) => {
        setValue(e.target.value)
      }}
      type={attributes.type}
      name={attributes.name}
      value={value}
      disabled={attributes.disabled || disabled}
      help={node.messages.length > 0}
      state={
        node.messages.find(({ type }) => type === 'error') ? 'error' : undefined
      }
      subtitle={
        <>
          {node.messages.map(({ text, id }) => (
            <span data-testid={`ui.node.message.${id}`}>{text}</span>
          ))}
        </>
      }
    />
  )
}

The simplest HTML input to render is the hidden input field. Basically you just add the attributes to the HTML element:

import { NodeInputProps } from './helpers'

export function NodeInputHidden<T>({ attributes }: NodeInputProps) {
  // Render a hidden input field
  return (
    <input
      type={attributes.type}
      name={attributes.name}
      value={attributes.value || 'true'}
    />
  )
}

Rendering a normal input field looks similar:

import { getNodeLabel } from '@ory/integrations/ui'
import { Button, TextInput } from '@ory/themes'

import { NodeInputButton } from './NodeInputButton'
import { NodeInputCheckbox } from './NodeInputCheckbox'
import { NodeInputHidden } from './NodeInputHidden'
import { NodeInputSubmit } from './NodeInputSubmit'
import { NodeInputProps } from './helpers'

export function NodeInputDefault<T>(props: NodeInputProps) {
  const { node, attributes, value = '', setValue, disabled } = props

  // Some attributes have dynamic JavaScript - this is for example required for WebAuthn.
  const onClick = () => {
    // This section is only used for WebAuthn. The script is loaded via a <script> node
    // and the functions are available on the global window level. Unfortunately, there
    // is currently no better way than executing eval / function here at this moment.
    if (attributes.onclick) {
      const run = new Function(attributes.onclick)
      run()
    }
  }

  // Render a generic text input field.
  return (
    <TextInput
      title={node.meta.label?.text}
      onClick={onClick}
      onChange={(e) => {
        setValue(e.target.value)
      }}
      type={attributes.type}
      name={attributes.name}
      value={value}
      disabled={attributes.disabled || disabled}
      help={node.messages.length > 0}
      state={
        node.messages.find(({ type }) => type === 'error') ? 'error' : undefined
      }
      subtitle={
        <>
          {node.messages.map(({ text, id }, k) => (
            <span key={`${id}-${k}`} data-testid={`ui/message/${id}`}>
              {text}
            </span>
          ))}
        </>
      }
    />
  )
}

Ory Kratos' forms can contain several types of nodes:

These are needed to show, for example, QR codes for TOTP, scripts for WebAuthn, text for recovery codes, buttons for social sign in, and so on!

Dealing With Flow Errors

Usually, Ory Kratos takes care of redirecting to the correct endpoints and showing the right messages. With Single Page Apps though you need to deal with errors yourself. Ory Kratos conveniently returns error IDs which you can use to identify errors and handle accordingly:

import { AxiosError } from 'axios'
import { NextRouter } from 'next/router'
import { Dispatch, SetStateAction } from 'react'
import { toast } from 'react-toastify'

// A small function to help us deal with errors coming from fetching a flow.
export function handleGetFlowError<S>(
  router: NextRouter,
  flowType: 'login' | 'registration' | 'settings' | 'recovery' | 'verification',
  resetFlow: Dispatch<SetStateAction<S | undefined>>
) {
  return async (err: AxiosError) => {
    switch (err.response?.data.error?.id) {
      case 'session_aal2_required':
        // 2FA is enabled and enforced, but user did not perform 2fa yet!
        window.location.href = err.response?.data.redirect_browser_to
        return
      case 'session_already_available':
        // User is already signed in, let's redirect them home!
        await router.push('/')
        return
      case 'session_refresh_required':
        // We need to re-authenticate to perform this action
        window.location.href = err.response?.data.redirect_browser_to
        return
      case 'self_service_flow_return_to_forbidden':
        // The flow expired, let's request a new one.
        toast.error('The return_to address is not allowed.')
        resetFlow(undefined)
        await router.push('/' + flowType)
        return
      case 'self_service_flow_expired':
        // The flow expired, let's request a new one.
        toast.error('Your interaction expired, please fill out the form again.')
        resetFlow(undefined)
        await router.push('/' + flowType)
        return
      case 'security_csrf_violation':
        // A CSRF violation occurred. Best to just refresh the flow!
        toast.error(
          'A security violation was detected, please fill out the form again.'
        )
        resetFlow(undefined)
        await router.push('/' + flowType)
        return
      case 'security_identity_mismatch':
        // The requested item was intended for someone else. Let's request a new flow...
        resetFlow(undefined)
        await router.push('/' + flowType)
        return
      case 'browser_location_change_required':
        // Ory Kratos asked us to point the user to this URL.
        window.location.href = err.response.data.redirect_browser_to
        return
    }

    switch (err.response?.status) {
      case 410:
        // The flow expired, let's request a new one.
        resetFlow(undefined)
        await router.push('/' + flowType)
        return
    }

    // We are not able to handle the error? Return it.
    return Promise.reject(err)
  }
}

// A small function to help us deal with errors coming from initializing a flow.
export const handleFlowError = handleGetFlowError

Rendering the Recovery Page

Rendering the recovery form is the same as the registration form, but with a few minor changes:

import {
  SelfServiceRecoveryFlow,
  SubmitSelfServiceRecoveryFlowBody
} from '@ory/kratos-client'
import { CardTitle } from '@ory/themes'
import { AxiosError } from 'axios'
import type { NextPage } from 'next'
import Head from 'next/head'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'

import { Flow, ActionCard, CenterLink, MarginCard } from '../pkg'
import { handleFlowError } from '../pkg/errors'
import ory from '../pkg/sdk'

const Recovery: NextPage = () => {
  const [flow, setFlow] = useState<SelfServiceRecoveryFlow>()

  // Get ?flow=... from the URL
  const router = useRouter()
  const { flow: flowId, return_to: returnTo } = router.query

  useEffect(() => {
    // If the router is not ready yet, or we already have a flow, do nothing.
    if (!router.isReady || flow) {
      return
    }

    // If ?flow=.. was in the URL, we fetch it
    if (flowId) {
      ory
        .getSelfServiceRecoveryFlow(String(flowId))
        .then(({ data }) => {
          setFlow(data)
        })
        .catch(handleFlowError(router, 'recovery', setFlow))
      return
    }

    // Otherwise we initialize it
    ory
      .initializeSelfServiceRecoveryFlowForBrowsers()
      .then(({ data }) => {
        setFlow(data)
      })
      .catch(handleFlowError(router, 'recovery', setFlow))
      .catch((err: AxiosError) => {
        // If the previous handler did not catch the error it's most likely a form validation error
        if (err.response?.status === 400) {
          // Yup, it is!
          setFlow(err.response?.data)
          return
        }

        return Promise.reject(err)
      })
  }, [flowId, router, router.isReady, returnTo, flow])

  const onSubmit = (values: SubmitSelfServiceRecoveryFlowBody) =>
    router
      // On submission, add the flow ID to the URL but do not navigate. This prevents the user loosing
      // his data when she/he reloads the page.
      .push(`/recovery?flow=${flow?.id}`, undefined, { shallow: true })
      .then(() =>
        ory
          .submitSelfServiceRecoveryFlow(String(flow?.id), undefined, values)
          .then(({ data }) => {
            // Form submission was successful, show the message to the user!
            setFlow(data)
          })
          .catch(handleFlowError(router, 'recovery', setFlow))
          .catch((err: AxiosError) => {
            switch (err.response?.status) {
              case 400:
                // Status code 400 implies the form validation had an error
                setFlow(err.response?.data)
                return
            }

            throw err
          })
      )

  return (
    <>
      <Head>
        <title>Recover your account - Ory NextJS Integration Example</title>
        <meta name="description" content="NextJS + React + Vercel + Ory" />
      </Head>
      <MarginCard>
        <CardTitle>Recover your account</CardTitle>
        <Flow onSubmit={onSubmit} flow={flow} />
      </MarginCard>
      <ActionCard>
        <Link href="/" passHref>
          <CenterLink>Go back</CenterLink>
        </Link>
      </ActionCard>
    </>
  )
}

export default Recovery

Rendering the Verification Page

Rendering the verification form is the same as the registration form, but with a few minor changes:

import {
  SelfServiceVerificationFlow,
  SubmitSelfServiceVerificationFlowBody
} from '@ory/kratos-client'
import { Card, CardTitle } from '@ory/themes'
import { AxiosError } from 'axios'
import type { NextPage } from 'next'
import Head from 'next/head'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'

import ory from '../pkg/sdk'
import { ActionCard, CenterLink, MarginCard } from '../pkg/styled'
import { Flow } from '../pkg/ui/Flow'

const Verification: NextPage = () => {
  const [flow, setFlow] = useState<SelfServiceVerificationFlow>()

  // Get ?flow=... from the URL
  const router = useRouter()
  const { flow: flowId } = router.query

  useEffect(() => {
    if (!router.isReady) {
      return
    }

    // If ?flow=.. was in the URL, we fetch it
    if (flowId) {
      ory
        .getSelfServiceVerificationFlow(String(flowId))
        .then(({ data }) => {
          setFlow(data)
        })
        .catch((err: AxiosError) => {
          switch (err.response?.status) {
            case 410:
            // Status code 410 means the request has expired - so let's load a fresh flow!
            case 403:
              // Status code 403 implies some other issue (e.g. CSRF) - let's reload!
              return router.push('/verification')
          }

          throw err
        })
      return
    }

    // Otherwise we initialize it
    ory
      .initializeSelfServiceVerificationFlowForBrowsers()
      .then(({ data }) => {
        setFlow(data)
      })
      .catch((err: AxiosError) => {
        switch (err.response?.status) {
          case 400:
            // Status code 400 implies the user is already signed in
            return router.push('/')
        }

        throw err
      })
  }, [flowId, router, router.isReady])

  const onSubmit = (values: SubmitSelfServiceVerificationFlowBody) =>
    router
      // On submission, add the flow ID to the URL but do not navigate. This prevents the user loosing
      // his data when she/he reloads the page.
      .push(`/verification?flow=${flow?.id}`, undefined, { shallow: true })
      .then(() =>
        ory
          .submitSelfServiceVerificationFlow(
            String(flow?.id),
            undefined,
            values
          )
          .then(({ data }) => {
            // Form submission was successful, show the message to the user!
            setFlow(data)
          })
          .catch((err: AxiosError) => {
            switch (err.response?.status) {
              case 400:
                // Status code 400 implies the form validation had an error
                setFlow(err.response?.data)
                return
            }

            throw err
          })
      )

  return (
    <>
      <Head>
        <title>Verify your account - Ory NextJS Integration Example</title>
        <meta name="description" content="NextJS + React + Vercel + Ory" />
      </Head>
      <MarginCard>
        <CardTitle>Verify your account</CardTitle>
        <Flow onSubmit={onSubmit} flow={flow} />
      </MarginCard>
      <ActionCard>
        <Link href="/" passHref>
          <CenterLink>Go back</CenterLink>
        </Link>
      </ActionCard>
    </>
  )
}

export default Verification

Rendering the Account Settings Page

Rendering the account settings form is the same as the registration form, but with a few minor changes:

import {
  SelfServiceSettingsFlow,
  SubmitSelfServiceRegistrationFlowBody,
  SubmitSelfServiceSettingsFlowBody
} from '@ory/kratos-client'
import { CardTitle, H3, P } from '@ory/themes'
import { AxiosError } from 'axios'
import type { NextPage } from 'next'
import Head from 'next/head'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { ReactNode, useEffect, useState } from 'react'

import ory from '../pkg/sdk'
import { ActionCard, CenterLink } from '../pkg/styled'
import { Flow, Methods } from '../pkg/ui/Flow'
import { Messages } from '../pkg/ui/Messages'

interface Props {
  flow?: SelfServiceSettingsFlow
  only?: Methods
}

function SettingsCard({
  flow,
  only,
  children
}: Props & { children: ReactNode }) {
  if (!flow) {
    return null
  }

  const nodes = only
    ? flow.ui.nodes.filter(({ group }) => group === only)
    : flow.ui.nodes

  if (nodes.length === 0) {
    return null
  }

  return <ActionCard wide>{children}</ActionCard>
}

const Settings: NextPage = () => {
  const [flow, setFlow] = useState<SelfServiceSettingsFlow>()

  // Get ?flow=... from the URL
  const router = useRouter()
  const { flow: flowId } = router.query

  useEffect(() => {
    if (!router.isReady) {
      return
    }

    // If ?flow=.. was in the URL, we fetch it
    if (flowId) {
      ory
        .getSelfServiceSettingsFlow(String(flowId))
        .then(({ data }) => {
          setFlow(data)
        })
        .catch((err: AxiosError) => {
          switch (err.response?.status) {
            case 410:
            // Status code 410 means the request has expired - so let's load a fresh flow!
            case 403:
              // Status code 403 implies some other issue (e.g. CSRF) - let's reload!
              return router.push('/settings')
          }

          throw err
        })
      return
    }

    // Otherwise we initialize it
    ory.initializeSelfServiceSettingsFlowForBrowsers().then(({ data }) => {
      setFlow(data)
    })
  }, [flowId, router, router.isReady])

  const onSubmit = (values: SubmitSelfServiceSettingsFlowBody) =>
    router
      // On submission, add the flow ID to the URL but do not navigate. This prevents the user loosing
      // his data when she/he reloads the page.
      .push(`/settings?flow=${flow?.id}`, undefined, { shallow: true })
      .then(() => {
        ory
          .submitSelfServiceSettingsFlow(String(flow?.id), undefined, values)
          .then(({ data }) => {
            // The settings have been saved and the flow was updated. Let's show it to the user!
            setFlow(data)
          })
          .catch((err: AxiosError) => {
            switch (err.response?.status) {
              case 400:
                // Status code 400 implies the form validation had an error
                setFlow(err.response?.data)
                return
            }

            throw err
          })
      })

  return (
    <>
      <Head>
        <title>
          Profile Management and Security Settings - Ory NextJS Integration
          Example
        </title>
        <meta name="description" content="NextJS + React + Vercel + Ory" />
      </Head>
      <CardTitle style={{ marginTop: 80 }}>
        Profile Management and Security Settings
      </CardTitle>
      <SettingsCard only="profile" flow={flow}>
        <H3>Profile Settings</H3>
        <Messages messages={flow?.ui.messages} />
        <Flow
          hideGlobalMessages
          onSubmit={onSubmit}
          only="profile"
          flow={flow}
        />
      </SettingsCard>
      <SettingsCard only="password" flow={flow}>
        <H3>Change Password</H3>

        <Messages messages={flow?.ui.messages} />
        <Flow
          hideGlobalMessages
          onSubmit={onSubmit}
          only="password"
          flow={flow}
        />
      </SettingsCard>
      <SettingsCard only="oidc" flow={flow}>
        <H3>Manage Social Sign In</H3>

        <Messages messages={flow?.ui.messages} />
        <Flow hideGlobalMessages onSubmit={onSubmit} only="oidc" flow={flow} />
      </SettingsCard>
      <SettingsCard only="lookup_secret" flow={flow}>
        <H3>Manage 2FA Backup Recovery Codes</H3>
        <Messages messages={flow?.ui.messages} />
        <P>
          Recovery codes can be used in panic situations where you have lost
          access to your 2FA device.
        </P>

        <Flow
          hideGlobalMessages
          onSubmit={onSubmit}
          only="lookup_secret"
          flow={flow}
        />
      </SettingsCard>
      <SettingsCard only="totp" flow={flow}>
        <H3>Manage 2FA TOTP Authenticator App</H3>
        <P>
          Add a TOTP Authenticator App to your account to improve your account
          security. Popular Authenticator Apps are{' '}
          <a href="https://www.lastpass.com" rel="noreferrer" target="_blank">
            LastPass
          </a>{' '}
          and Google Authenticator (
          <a
            href="https://apps.apple.com/us/app/google-authenticator/id388497605"
            target="_blank"
            rel="noreferrer"
          >
            iOS
          </a>
          ,{' '}
          <a
            href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en&gl=US"
            target="_blank"
            rel="noreferrer"
          >
            Android
          </a>
          ).
        </P>
        <Messages messages={flow?.ui.messages} />
        <Flow hideGlobalMessages onSubmit={onSubmit} only="totp" flow={flow} />
      </SettingsCard>
      <SettingsCard only="webauthn" flow={flow}>
        <H3>Manage Hardware Tokens and Biometrics</H3>
        <Messages messages={flow?.ui.messages} />
        <P>
          Use Hardware Tokens (e.g. YubiKey) or Biometrics (e.g. FaceID,
          TouchID) to enhance your account security.
        </P>
        <Flow
          hideGlobalMessages
          onSubmit={onSubmit}
          only="webauthn"
          flow={flow}
        />
      </SettingsCard>
      <ActionCard wide>
        <Link href="/" passHref>
          <CenterLink>Go back</CenterLink>
        </Link>
      </ActionCard>
    </>
  )
}

export default Settings

Rendering the Login Page

The login page is a bit more work to render! That is because we want to support two-step authentication and we need to deal with any two-factor authentication errors by e.g. logging the user out.

import {
  SelfServiceLoginFlow,
  SubmitSelfServiceLoginFlowBody
} from '@ory/kratos-client'
import { CardTitle } from '@ory/themes'
import { AxiosError } from 'axios'
import type { NextPage } from 'next'
import Head from 'next/head'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'

import {
  ActionCard,
  CenterLink,
  createLogoutHandler,
  Flow,
  MarginCard
} from '../pkg'
import { handleGetFlowError, handleFlowError } from '../pkg/errors'
import ory from '../pkg/sdk'

const Login: NextPage = () => {
  const [flow, setFlow] = useState<SelfServiceLoginFlow>()

  // Get ?flow=... from the URL
  const router = useRouter()
  const {
    return_to: returnTo,
    flow: flowId,
    // Refresh means we want to refresh the session. This is needed, for example, when we want to update the password
    // of a user.
    refresh,
    // AAL = Authorization Assurance Level. This implies that we want to upgrade the AAL, meaning that we want
    // to perform two-factor authentication/verification.
    aal
  } = router.query

  // This might be confusing, but we want to show the user an option
  // to sign out if they are performing two-factor authentication!
  const onLogout = createLogoutHandler([aal, refresh])

  useEffect(() => {
    // If the router is not ready yet, or we already have a flow, do nothing.
    if (!router.isReady || flow) {
      return
    }

    // If ?flow=.. was in the URL, we fetch it
    if (flowId) {
      ory
        .getSelfServiceLoginFlow(String(flowId))
        .then(({ data }) => {
          setFlow(data)
        })
        .catch(handleGetFlowError(router, 'login', setFlow))
      return
    }

    // Otherwise we initialize it
    ory
      .initializeSelfServiceLoginFlowForBrowsers(
        Boolean(refresh),
        aal ? String(aal) : undefined,
        returnTo ? String(returnTo) : undefined
      )
      .then(({ data }) => {
        setFlow(data)
      })
      .catch(handleFlowError(router, 'login', setFlow))
  }, [flowId, router, router.isReady, aal, refresh, returnTo, flow])

  const onSubmit = (values: SubmitSelfServiceLoginFlowBody) =>
    router
      // On submission, add the flow ID to the URL but do not navigate. This prevents the user loosing
      // his data when she/he reloads the page.
      .push(`/login?flow=${flow?.id}`, undefined, { shallow: true })
      .then(() =>
        ory
          .submitSelfServiceLoginFlow(String(flow?.id), undefined, values)
          // We logged in successfully! Let's bring the user home.
          .then((res) => {
            if (flow?.return_to) {
              window.location.href = flow?.return_to
              return
            }
            router.push('/')
          })
          .then(() => {})
          .catch(handleFlowError(router, 'login', setFlow))
          .catch((err: AxiosError) => {
            // If the previous handler did not catch the error it's most likely a form validation error
            if (err.response?.status === 400) {
              // Yup, it is!
              setFlow(err.response?.data)
              return
            }

            return Promise.reject(err)
          })
      )

  return (
    <>
      <Head>
        <title>Sign in - Ory NextJS Integration Example</title>
        <meta name="description" content="NextJS + React + Vercel + Ory" />
      </Head>
      <MarginCard>
        <CardTitle>
          {(() => {
            if (flow?.forced) {
              return 'Confirm Action'
            } else if (flow?.requested_aal === 'aal2') {
              return 'Two-Factor Authentication'
            }
            return 'Sign In'
          })()}
        </CardTitle>
        <Flow onSubmit={onSubmit} flow={flow} />
      </MarginCard>
      {aal || refresh ? (
        <ActionCard>
          <CenterLink data-testid="logout-link" onClick={onLogout}>
            Log out
          </CenterLink>
        </ActionCard>
      ) : (
        <>
          <ActionCard>
            <Link href="/registration" passHref>
              <CenterLink>Create account</CenterLink>
            </Link>
          </ActionCard>
          <ActionCard>
            <Link href="/recover" passHref>
              <CenterLink>Recover your account</CenterLink>
            </Link>
          </ActionCard>
        </>
      )}
    </>
  )
}

export default Login

Form Rendering Conclusion

That was quite a bit of code, but it's all there is to it! If you do not want to implement these UI screens yourself, use the reference implementations for Ory Kratos instead, or clone this repository and use it as a base for your project!

git clone https://github.com/ory/kratos-selfservice-ui-react-nextjs.git

Deploy to Vercel

The easiest way to deploy your Next.js app is to use the Vercel Platform from the creators of Next.js. If you have never deployed on Vercel, check out the Next.js deployment documentation for more details. Deploying the app is easy. Ensure that your build works by running

npm run build

Then, set up your Vercel account and create a new app. You will need to configure your the Ory Network Project SDK URL or the URL of your self-hosted Ory Kratos instance in your Vercel deployment:

Add the Ory Network SDK URL to Vercel

By the way! If you want to use separate Ory Kratos deployments for staging, production, and development then use different SDK URLs for the different environments by un/selecting the checkboxes in the Vercel UI:

Use a development project for development Vercel

Use a production project for production Vercel

If you want to call the Ory Network's Admin APIs from your Next.js Edge serverless functions, optionally set up the Ory Personal Access Token:

Connect Vercel with Ory Personal Access Token

Next all you need to do is to run the deploy command and connect it to the project you created:

npx vercel deploy --prod

This also works with Vercel PR Preview!

End-to-End Tests

Adding end-to-end tests is also easy! Clone the repository and run the following commands:

git clone https://github.com/ory/kratos-selfservice-ui-react-nextjs.git
cd kratos-selfservice-ui-react-nextjs
npm i

Then, depending on your setup, you can either use Ory Kratos local or in Ory Cloud:

export ORY_KRATOS_URL=https://playground.projects.oryapis.com/

Then, build and start the server

npm run dev

and in a new shell run the end-to-end tests:

npm run test:dev

You can find the full spec file in the cypress/integration/pages.spec.js file:

const randomString = () => (Math.random() + 1).toString(36).substring(7)
const randomPassword = () => randomString() + randomString()
const randomEmail = () => randomString() + "@" + randomString() + ".com"

context("Ory Kratos pages", () => {
  const email = randomEmail()
  const password = randomPassword()

  beforeEach(() => {
    cy.clearCookies({ domain: null })
  })

  it("can load the login page", () => {
    cy.visit("/login")
    cy.get('[name="method"]').should("exist")
  })

  it("can load the registration page", () => {
    cy.visit("/registration")
    cy.get('[name="traits.email"]').type(email)
    cy.get('[name="password"]').type(password)
    cy.get('[name="method"]').click()
    cy.location("pathname").should("eq", "/verification")

    cy.visit("/")
    cy.get('[data-testid="logout"]').should(
      "have.attr",
      "aria-disabled",
      "false",
    )
    cy.get('[data-testid="session-content"]').should("contain.text", email)
  })

  it("can load the verification page", () => {
    cy.visit("/verification")
    cy.get('[name="method"]').should("exist")
  })

  it("can load the recovery page", () => {
    cy.visit("/recovery")
    cy.get('[name="method"]').should("exist")
  })

  it("can load the welcome page", () => {
    cy.visit("/")
    cy.get("h2").should("exist")
  })
})

The GitHub Action file is also straight forward and contains two configurations, one for running Ory Kratos locally and one for running Ory Kratos in the Ory Network:

name: Run Tests
on:
  pull_request:
  push:
    branches:
      - main
      - master
  # Run this test every day to catch any regressions.
  schedule:
    - cron: "0 0 * * *"

jobs:
  production:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: "16"
      - run: npm ci
      - run: npm run format:check
      - run: npm run build
      - run: |
          npm run start &
          npm run test
        env:
          ORY_KRATOS_URL: https://playground.projects.oryapis.com/

  staging:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: "16"
      - run: npm ci
      - run: npm run format:check
      - run: npm run build
      - run: |
          npm run start &
          npm run test
        env:
          ORY_KRATOS_URL: https://blissful-greider-9hmtg26xai.projects.staging.oryapis.dev/

  self-hosted:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: "16"
      - run: npm ci
      - run: npm run format:check
      - run: npm run build
      - run: |
          git clone --depth 1 --branch master https://github.com/ory/kratos.git ../kratos
          cd ../kratos
          git checkout master
          make docker
          docker-compose -f quickstart.yml -f quickstart-latest.yml -f contrib/quickstart/kratos/cloud/quickstart.yml up --build --force-recreate -d
      - run: |
          npm run start &
          npm run test
        env:
          ORY_KRATOS_URL: http://localhost:4455

Conclusion

Adding login and registration to your Next.js app is a breeze with open source technology like Ory Kratos and Next.js.

We hope you enjoyed this guide and found it helpful! If you have any questions, check out the Ory community on Slack and GitHub!