DEV Community

Cover image for Creating a Custom UI for Ory Cloud with React/Nextjs
Daniel Griffiths
Daniel Griffiths

Posted on

Creating a Custom UI for Ory Cloud with React/Nextjs

Ory is a cloud (or self-hosted) authentication and authorisation service.

Like many authentication/authorisation-as-a-service providers, it comes with minimally customisable screens to enable users to login, register, verify emails and the like.

Also like many authentication providers, if you need to do anything except the bare minimum in customisation, it is not simple. In Ory's case, you will need to self-host custom UI pages (a custom login page, custom registration page etc.).

This article will show you how you can create your own customised UI screens for a hosted ory cloud instance using React (specifically nextjs in my case, though vanilla React would be very similar).

Why am I writing this? Why not use the ory documentation?

To be blunt, Ory's documentation around custom UIs is not good.

There are links to articles that are 5 years old and out of date. There are links to packages that have been superseded by similarly named but slightly different newer packages. There are links to example codebases that use deprecated packages or are poorly documented or do not run, or any combination of the three.

It took me days to work out what I needed to do to get a custom UI up and running. My hope with this article is to give people a real starting point and save them some of my pain.

Setup

You will need:

Within the nextjs app, these were the packages (and versions) that I used:

  • @ory/nextjs - 1.0.0-rc.0
  • @ory/elements-react - 1.0.0-rc.5
  • next - 15.2.4 (may work fine with other versions)
  • react and react-dom - 19.0.0 (may work fine with other versions)

Other packages I investigated

@ory/elements - Seems to be deprecated. A note in the readme recommends using @ory/elements-react, despite plenty of the ory documentation linking directly to this elements package.

@ory/client - This is an alternative to @ory/nextjs. It is not deprecated according to the readme, but it seems to be recommended to use @ory/client-fetch instead, a package that functions similarly but uses fetch instead of axios. The code lives here.

@ory/client-fetch - This appears to be the recommended package to use for getting flows from the client side. If I were to use vanilla React and not nextjs, this is what I would use instead of @ory/nextjs. Code can be found here.

@ory/integrations - again despite being mentioned in the official documentation, this package is marked as deprecated by the readme on github - see here.

Useful example projects

I trawled through a number of the example codebases in ory's github. Of those, only one is useful - this one:

https://github.com/ory/elements/tree/main/examples/nextjs-app-router

it uses the combination of packages I eventually used to get my custom UI working. Due to poor documentation I was not able to get this example project (nor any of the example projects) running locally, but this one came closest.

At the time of writing (17/06/2025) I would not recommend reading any of the other official ory example projects. I did, and they did nothing but confuse me with poor documentation, non-working code or references to deprecated packages.

Overview of process

Before diving into the steps in the next section, it is worth understanding what we need to do at a high level.

1. Get the custom UI and ory onto the same domain

To communicate securely with ory network, the custom UI must be hosted on the same top level domain as your ory instance.

This is because it uses a CSRF cookie to communicate between ory and the custom UI app. More can be found about this here.

In production, this will mean you need to give your ory instance a custom URL at one subdomain and then host your custom UI at another subdomain, for example ory could be given the custom url signin.website.co.uk and your custom UI could be hosted at direct.website.co.uk.

In development, the easiest way to do this is by using ory tunnel (which is available through the ory CLI). This will make your ory instance available at a localhost port. This is what we will be doing.

2. Flow IDs

The custom UI gets information from ory by exchanging flow IDs for flow objects.

These flow objects detail what UI elements to display, validation errors, current state, CSRF tokens to include when submitting data back to ory and generally everything you need to guide a user through whatever flow they've commenced.

The easiest way to exchange these is by using ory sdk libraries. In my case this was @ory/nextjs.

Note:
It is theoretically possible to exchange flows and submit login requests etc. without using libraries, however it was not clear exactly what I needed to do from the documentation and I was not able to get it to work.

An example flow object for logging in can be found below:

{
  "created_at": {},
  "expires_at": {},
  "id": "761fdc7a-327a-4d6c-bbb1-ba8d2cd05618",
  "issued_at": {},
  "refresh": false,
  "request_url": "http://localhost:3000/self-service/login/browser?aal=&refresh=&return_to=&organization=&via=",
  "requested_aal": "aal1",
  "state": "choose_method",
  "type": "browser",
  "ui": {
    "action": "http://localhost:3000/self-service/login?flow=761fdc7a-327a-4d6c-bbb1-ba8d2cd05618",
    "method": "POST",
    "nodes": [
      {
        "attributes": {
          "disabled": false,
          "name": "csrf_token",
          "node_type": "input",
          "required": true,
          "type": "hidden",
          "value": "csrf_value"
        },
        "group": "default",
        "messages": [],
        "meta": {},
        "type": "input"
      },
      {
        "attributes": {
          "disabled": false,
          "name": "identifier",
          "node_type": "input",
          "required": true,
          "type": "text",
          "value": ""
        },
        "group": "default",
        "messages": [],
        "meta": {
          "label": {
            "id": 1070004,
            "text": "ID",
            "type": "info"
          }
        },
        "type": "input"
      },
      {
        "attributes": {
          "autocomplete": "current-password",
          "disabled": false,
          "name": "password",
          "node_type": "input",
          "required": true,
          "type": "password"
        },
        "group": "password",
        "messages": [],
        "meta": {
          "label": {
            "id": 1070001,
            "text": "Password",
            "type": "info"
          }
        },
        "type": "input"
      },
      {
        "attributes": {
          "disabled": false,
          "name": "method",
          "node_type": "input",
          "type": "submit",
          "value": "password"
        },
        "group": "password",
        "messages": [],
        "meta": {
          "label": {
            "id": 1010022,
            "text": "Sign in with password",
            "type": "info"
          }
        },
        "type": "input"
      }
    ]
  },
  "updated_at": {}
}
Enter fullscreen mode Exit fullscreen mode

3. Interpret the flow object

Given the flow object received from ory, you must interpret this into UI that the user can use to complete their flow (to login, or reset their password etc.).

The easiest way to do this is with the @ory/elements-react package.

The official ory documentation around this process can be found here.

Steps

Now we'll cover the specific steps you need to take to set up a custom UI. We'll start with a login page, but the process is similar for recovery, settings etc.

1. Set up ory tunnel

As prerequisites for this step, I am assuming you have already signed up to ory cloud and have at least one project running.

I am additionally assuming that you have the ory CLI set up, if not it can be downloaded from here.

After that, we will need to run the following command to set up the tunnel to your ory project:

ory tunnel --workspace workspace-id --project project-id http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

This will open up a tunnel between the project on the given workspace-id with the given project-id and your localhost.

This command assumes your custom UI will be running at http://localhost:3000, if it is running on a different port, you should use that port instead.

If you don't have your workspace and project ids, you can get them by using the following commands:

ory list workspaces
Enter fullscreen mode Exit fullscreen mode

and

ory list projects --workspace [workspace-id]
Enter fullscreen mode Exit fullscreen mode

then simply find the relevant workspace-id and project-id and replace the placeholders in the tunnel command above.

After you have run the command, visit http://localhost:4000/ui/login to see the default hosted ory login page.

Note: The ory tunnel runs on port 4000 by default. This can be overridden if you like, see the documentation linked here.

2. Environment setup

First, create a nextjs app satisfying the requirements as detailed at the start of this article. As a reminder, these mainly are:

  • node - 22.11.0
  • @ory/elements-react package - version 1.0.0-rc.5
  • @ory/nextjs package - version 1.0.0-rc.0

Aside from these, I used the following packages at the following versions:

  • next - 15.2.4
  • react and react-dom - 19.0.0

Finally you will need to add theORY_SDK_URL value to your .env file.

ORY_SDK_URL=http://localhost:4000
Enter fullscreen mode Exit fullscreen mode

3. Create basic custom login page

With these all installed, add a new page at src/app/auth/login/page.tsx.

Note: I used the app router but it should work perfectly fine with the pages router too. Similarly I used typescript but it should work perfectly fine with jsx files).

This is the bare minimum required to get a custom login page:

// /login/page.tsx

import { getLoginFlow, OryPageParams } from "@ory/nextjs/app";
import { Login } from "@ory/elements-react/theme";
import { OryClientConfiguration } from '@ory/elements-react';

const oryConfig: OryClientConfiguration = {
  sdk: {
    url: process.env.ORY_SDK_URL,
    options: {
      basePath: process.env.ORY_SDK_URL,
    },
  },
  project: {
    default_locale: 'en',
    default_redirect_url: '/',
    error_ui_url: '/error',
    locale_behavior: 'force_default',
    name: 'My Custom UI',
    registration_enabled: false,
    verification_enabled: true,
    recovery_enabled: true,
    registration_ui_url: '/auth/registration',
    verification_ui_url: '/auth/verification',
    recovery_ui_url: '/auth/recovery',
    login_ui_url: '/auth/login',
    settings_ui_url: '/settings',
  },
};

export default async function LoginPage(props: OryPageParams) {
  const flow = await getLoginFlow(oryConfig, props.searchParams);

  if (!flow) {
    return <div>Loading...</div>;
  }

  return <Login flow={flow} config={oryConfig} />;
}
Enter fullscreen mode Exit fullscreen mode

NOTE: The @ory/nextjs package should only be used server-side, otherwise it attempts to bake environment variables into static pages at build time. This is more a quirk between nextjs app router and the @ory/nextjs package than an actual bug, but worth pointing out because it caused me pain.

Production Note: In a production environment, the @ory/nextjs package will automatically use the ORY_SDK_URL without it needing to be specified in the config, but I've found that in development you need to specify it in the oryConfig explicitly.

4. Ory console config and finishing up

Finally, go to your project in the ory cloud console, then Branding > UI Urls

Image description

Then set the login UI Url to redirect to the page we just added:

Image description

Now, if you navigate to http://localhost:4000/ui/login, you should be redirected to your new custom page.

Congratulations!

Next Steps

More Styling

Because it is within your own react app, you can obviously style everything surrounding the flow at your own discretion. Additionally, you can override specific components within the flow too, for example here we wrap a custom card component around the default ory flow.

<Login
  flow={flow}
  config={oryConfig}
  components={{
    Card: {
      Root: ({ children }) => (
        <LoginCard>
          <div className="ory-form-wrapper">{children}</div>
        </LoginCard>
      ),
    },
  }}
/>
Enter fullscreen mode Exit fullscreen mode

I believe you can also override custom css classes too, though I have not needed to do this myself.

Note: You can override individual inputs by specifying components for them like above, however unless they are specified very carefully, inputs created in such a way will break form submission. I have not yet worked out how to specify these inputs such that submission is not broken.

More Pages

The example above is for the login flow, but there are similar components within ory elements for each of the standard flows, for example getRecoveryFlow from @ory/nextjs to get the recovery flow and Recovery from @ory/elements-react to display it.

See the pages in the app router in this project for basic examples of other pages.

Production Deployment

As long as the custom UI is hosted on the same domain as ory, and the UI URLs are set up correctly in the ory network console, this should work similarly to the development environment.

Debugging Tips

If you hit any roadblocks setting anything up - I recommend joining the ory slack. Within it they have an AI agent on a channel which is usually able to point you in the right direction and is generally quite helpful.

Documentation - as stated previously in this article - is hit and miss. Some pages are extremely useful, some are extremely out of date, and there is no way to tell which is which except painful trial and error.

Generative AIs (chatGPT, claude and the like) are next to useless in my experience - ory is too niche for them to be able to work well. I got 50/50 hallucinations to useful information, though your mileage may vary.

Conclusion

Creating a custom UI for my ory cloud instance was a painful process of trial and error.

My hope is that this article can take away some of that pain for other developers that need to do this in future.

Top comments (0)