Tutorial

Next.js Custom Domains Guide

A tutorial guide for supporting user custom domains as a feature in your Next.js app, with an example github repo.

This guide is meant to show you how to allow users of your Next.js app to connect their own domains and load up content specific to them. Custom domains - sometimes called vanity domains, whitelabel domains, or customer domains - are a key feature of many apps.

If you need to manage custom domains with SSL for SAAS, marketplaces, blog hosts, email service providers, or other platforms, then this is the guide for you.

What you'll learn in this guide:

  • Domains. We'll briefly go over web domains, how they work, and how they can be used as custom domains with your Next.js app.
  • Networking. For your Next.js app to handle custom domain requests, they first need to reach your server(s).
  • Pages Router. Once the request reaches your Next.js app, we show how you can differentiate custom domains with the Pages Router.
  • App Router. Alternatively, we show you a few different methods you can use to differentiate custom domains with the App Router.
  • Prerendering. We talk a bit about what prerendering, and how it can impact custom domains.
  • Bonus! How to automate the networking and SSL management easily with Approximated.

Web domains and how they work

You're likely pretty familiar with web domains, but we'll go over a few of the specifics of how domains work that can sometimes cause confusion.

Registrars and ownership

Domains are bought and managed with registrars like Hover, GoDaddy, Namecheap, and many others. This is where a domain is officialy registered and may or may not be where it's DNS is managed. For our purposes, we can assume that it will be the user that owns their own domain, which they would like to point at your app.

DNS and pointing domains

Domains don't do much unless they're pointed at a target server. This is done with DNS, which tells devices where to send requests for a domain, using DNS records. These records are set at the domain's DNS provider, which may or may not be the same as the registrar.

Apex Domains vs Subdomains

Apex domains are the root, naked domain with no subdomain attached. For instance, example.com is an apex domain.

A subdomain is what we call it when we prepend something to an apex domain. For example, sub.example.com or sub2.sub1.example.com are both subdomains.

Technically, subdomains are domains. For the purpose of this guide though, when we use the word domain, we're referring to an apex domain.

SSL, TLS, and encrypting requests

When a request is sent out over the internet, there are various points where it's contents could be inspected and tampered with unless they're encrypted.

This is especially important for any private data such as ecommerce or personal information, and may be legally required in many cases. It's generally a very good idea for all requests to be encrypted, in order to prevent censorship, request injections, snooping and other tampering.

SSL certificates - which are technically TLS certificates now, but often still called by the old name - are the standard by which browsers and other applications encrypt these requests. Using them requires a server with a provisioned SSL certificate for each domain. We'll go more into detail on this in the networking section below.

Networking and SSL for custom domains on Next.js

In order for your app to serve custom domains, the requests for each custom domain need to be networked to reach your server(s). For those requests to be secured, you'll also need to provide and manage an SSL certificate for each custom domain.

Networking - getting the request to your server

Each custom domain will need to have DNS records set to point requests at your app - either directly or indirectly. There are two options for how you can go about this, each with significant trade-offs that you should know about:

  1. DNS A records pointed at an IPv4 address

    DNS A records can only be pointed at an IPv4 address, formatted as x.x.x.x, where each x can be any value between 0 and 255.

    It's important to note that apex domains - those with no subdomain (including www) - can only be pointed with A records. They cannot be pointed with CNAME records.

    This is the easiest and most universal method but also carries the risk that if you ever need to change that IPv4 address, you'll need to have each user change their DNS individually.

  2. CNAME records pointed through an intermediate domain

    CNAME records cannot point an apex domain and cannot point at an IPv4 address, but they can point subdomains at any other domain or subdomain.

    For example, you could point subdomain.example.com at test.com and requests would be sent to wherever test.com is pointing.

    If it's acceptable in your use case to require custom domains have a subdomain, then you can do the following:

    • Point the A record for a subdomain that you own at your server IPv4 address.
    • When a custom domain is created, have your user point a CNAME record at the subdomain above.
    • Requests for the custom domain will reach wherever the intermediate subdomain is pointed.
    • This has the advantage of allowing you to re-point all of the custom domains at once, by changing the A record for the intermediate subdomain.
    • The disadvantage here is that those custom domains cannot be apex domains and must have a subdomain.

SSL Certificates - securing many custom domains

You've likely secured your app already with SSL certificates, but unfortunately those will only apply to requests going directly to that domain. SSL certificates cannot be shared amongst apex domains.

You're going to need SSL certificates for each individual custom domain that points at your app, to encrypt requests as well as ensure security and privacy for that domain specfically.

In order to do that you'll need an automated system to provision SSL certificates automatically when custom domains are created, including:

  • Requesting the generation of an SSL certificate from a Certificate Authority.
  • Accepting and appropriately completing the verification challenge from the Certficate Authority.
  • Renewing within a reasonable window before the certificate expiration date (typically 3 months).
  • Avoiding going over rate limits with Certificate Authorities.
  • Checking for certificates that have been revoked early by Certificate Authorities and replacing them.
  • Monitoring all of it to catch and avoid the many edge cases and unexpected issues that can cause customer facing downtime.

For those that need to build this in-house, we recommend Caddy as your starting point. However, this is a complex topic and could easily be many guides worth of content.

Building reliable SSL management, distributed networking, and monitoring in-house for more than a few domains can be difficult even with the help of amazing tools like Caddy. There are a wide variety of obscure pitfalls that often come at the worst times, and will usually cause customer-facing downtime. So just be aware that it will be an ongoing effort.

To keep this tutorial from exploding in length, we won't be covering building this in-house here. If you'd prefer to have a managed service handle this for you, please see our bonus section below.

Routing Options

There are multiple ways to handle routing in Next.js, depending on how you want your app to work. We cover the Pages Router and the App Router here, since those are the most typical approaches.

Some aspects are quite different between the two, and each has it's own set of trade-offs. If you haven't already chosen one, you can find a quick comparison here.

The Next.js Pages Router for Custom Domains

The Pages Router was the default way to route requests in Next.js until version 13, when they introduced the App Router. If your app makes use of the Pages Router, here are some options for you to handle custom domains.

Pages Router: Middleware

You can add middleware to the Pages Router that can capture requests before they reach your page, preparing requests or performing redirects.

Next.js recommends smaller operations when using middleware, such as rewriting a path, rather than complex data fetching or extensive session management. Depending on your app, this may be enough for your custom domain needs.

Here's an example of a simple middleware that could make some change based on the custom domain before continuing on:

 // Example repo file: middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  /**
  * Check if there's a header with the custom domain,
  * and if not just use the host header.
  * If you're using approximated.app the default is to
  * inject the header 'apx-incoming-host' with the custom domain.
  */
  const domain = request.headers.has('apx-incoming-host')
    ? request.headers.get('apx-incoming-host')
    : request.headers.get('host');

  // do something with the "domain"

  const response = NextResponse.next({
    request: {
      headers: request.headers,
    },
  });

  return response;
};

Pages Router: Using getServerSideProps

If you're using the Pages Router, using the getServerSideProps approach may be the best choice for you.

This method runs domain checking code on the server before rendering your pages, which may be important for some applications, and lets you pass logic as props to your page components.

Note: getServerSideProps can only be used on top-level pages, not inside components. For component-level server logic, consider using the app router with React Server Components.

Here's an example of using getServerSideProps to generate a different response based on the custom domain:

 // Example repo file: pages/index.ts
import type { InferGetServerSidePropsType, GetServerSideProps } from 'next';
import { Inter } from 'next/font/google';

const inter = Inter({ subsets: ['latin'] });

type ApproximatedPage = {
  domain: string;
}

export const getServerSideProps = (async ({ req }) => {
  /**
  * Check if there's a header with the custom domain,
  * and if not just use the host header.
  * If you're using approximated.app the default is to
  * inject the header 'apx-incoming-host' with the custom domain.
  */
  const domain = req.headers['apx-incoming-host'] || req.headers.host || process.env.NEXT_PUBLIC_APP_PRIMARY_DOMAIN;

  // do something with the "domain"

  return { props: { domain } }
}) as GetServerSideProps<ApproximatedPage>;

export default function Home({ domain }: InferGetServerSidePropsType<typeof getServerSideProps>) {
  return (
    <main
      className={`flex min-h-screen flex-col items-center justify-between p-24 ${inter.className}`}
    >
      {domain === process.env.NEXT_PUBLIC_APP_PRIMARY_DOMAIN ? 'Welcome to the primary domain' : `Welcome to the custom domain ${domain}`}
    </main>
  )
};

Pages Router: Client-side pages

Client-side pages can also handle domain information, utilizing window.location.hostname or window.location.host wrapped in a state callback.

This can be useful if you're loading data or content from an API after an initial client-side render, based on the custom domain. However, note that this can be pretty easily spoofed, so don't rely on this alone for authentication or authorization.

Here's an example of using the window.location.hostname on the client side to do something different when we detect a custom domain:

 // Example repo file: pages/page-csr.ts
import { useEffect, useState } from 'react';
import { Inter } from 'next/font/google';

const inter = Inter({ subsets: ['latin'] });

export default function Page() {
  const [domain, setDomain] = useState<string>(String(process.env.NEXT_PUBLIC_APP_PRIMARY_DOMAIN));

  useEffect(() => {
    // NOTE: consider the difference between `window.location.host` (includes port) and `window.location.hostname` (only host domain name)
    const pageDomain = typeof window !== 'undefined' ? window.location.hostname : process.env.NEXT_PUBLIC_APP_PRIMARY_DOMAIN;

    setDomain(String(pageDomain));
  }, []);

  return (
    <main
      className={`flex min-h-screen flex-col items-center justify-between p-24 ${inter.className}`}
    >
      {domain === process.env.NEXT_PUBLIC_APP_PRIMARY_DOMAIN ? 'Welcome to the primary domain' : `Welcome to the subdomain ${domain}`}
    </main>
  )
};

Next.js App Router for Custom Domains
(React Server Components)

When using the app router, you can run server-side code in your components. This allows you to check request details at the component level, which can be useful for conditional rendering based on the request domain, especially when coupled with authentication.

App Router: API Routes

API routes can perform similar functions to middleware, using the domain from the header in the rest of your code.

Here's an example of how you could use an API route to potentially do something different when a custom domain is detected.

 // Example repo file: pages/api/host.ts
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next'

type ResponseData = {
  message: string
}

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  /**
  * Check if there's a header with the custom domain,
  * and if not just use the host header.
  * If you're using approximated.app the default is to
  * inject the header 'apx-incoming-host' with the custom domain.
  */
  const domain = req.headers['apx-incoming-host'] || req.headers.host || process.env.NEXT_PUBLIC_APP_PRIMARY_DOMAIN;

  // do something with the "domain"

  res.status(200).json({ message: `Hello from ${domain}` });
};

App Router: Route Handlers

The new app directory allows the use of route handlers, which can access the domain from request headers, similar to other routing methods.

Here's an example of using the App Router route handlers to do something different for each custom domain:

 // Example repo file: app/app-hosts/route.ts
import { type NextRequest } from 'next/server';

export const dynamic = 'force-dynamic';

export async function GET(request: NextRequest) {
  /**
  * Check if there's a header with the custom domain,
  * and if not just use the host header.
  * If you're using approximated.app the default is to
  * inject the header 'apx-incoming-host' with the custom domain.
  */
  const domain = request.headers.has('apx-incoming-host')
    ? request.headers.get('apx-incoming-host')
    : request.headers.get('host');

  return Response.json({ message: `Hello from ${domain}` });
};

App Router: App Pages

Note: Not to be confused with the Pages Router, App Pages are something separate.

Server components in the app directory can make use of server side functions help manage domain-specific logic.

Here's an example of using server side functions in App Pages to render based on the custom domain:

 // Example repo file: app/ssr-page/page.tsx
import { headers } from 'next/headers';

export default function Page() {
  /**
  * Check if there's a header with the custom domain,
  * and if not just use the host header.
  * If you're using approximated.app the default is to
  * inject the header 'apx-incoming-host' with the custom domain.
  */
  const domain = headers().has('apx-incoming-host')
    ? headers().get('apx-incoming-host')
    : headers().get('host');

  return <h1>{domain === process.env.NEXT_PUBLIC_APP_PRIMARY_DOMAIN ? 'Welcome to the primary domain' : `Welcome to the custom domain ${domain}`}</h1>
}

App Router: Client-Side Pages

For client-side pages in the app directory, window.location.hostname or window.location.host can be used as long as the page uses "use client".

Note that this is happening client-side, so you may be more limited in what you can do. This is often useful for small template changes, or calling an API to get data or content for a specific custom domain.

Here's an example of using window.location.hostname to get the current domain and do something different:

 // Example repo file: app/csr-page/page.tsx
'use client';

export default function Page() {
  // NOTE: consider the difference between `window.location.host` (includes port) and `window.location.hostname` (only host domain name)
  const domain = typeof window !== 'undefined' ? window.location.host : process.env.NEXT_PUBLIC_APP_PRIMARY_DOMAIN;

  return <h1>{domain === process.env.NEXT_PUBLIC_APP_PRIMARY_DOMAIN ? 'Welcome to the primary domain' : `Welcome to the subdomain ${domain}`}</h1>
}

Pre-rendering for Custom Domains with Next.js

It is possible to pre-render pages that use a custom domain or otherwise render unique content based on a custom domain. With the right tools this can be quite easy with little or no app modification required.

In other cases this may require significant reimagining of your app architecture, and may need complex logic with frequent re-rendering. This could include fetching the list of all custom domains, creating a specific .env for each, running npm run build, and deploying the static output. This process would potentially need to be repeated for each client and whenever new custom domains are added.

Bonus: Automating networking and SSL with Approximated

Remember how we said above that building a system for managing SSL certificates at scale is difficult, expensive, and error prone? Well, we know that from personal (painful) experience.

It's why we built Approximated - so that you don't have to. And at a price you can afford, starting at $20/month instead of thousands.

Approximated is your fully managed solution for automating custom domains and SSL certificates. We stand up a dedicated reverse proxy cluster, with edge nodes globally distributed around the world, for each and every customer.

You get a dedicated IPv4 address with each cluster so that you have the option of allowing your users to point their apex domains. Custom domain traffic goes through the cluster node nearest your user and straight to your app.

The best part? Your cluster will provision and fully manage SSL certificates automatically along the way. No need to worry about them ever again.

All you need to do is call our simple API from your app when you want to create, read, update, or delete a custom domain and it will handle the rest.

So what does that look like in our example repo?

It can be as simple as an API endpoint like this:

// in pages/api/createVirtualHost.ts
import type { NextApiRequest, NextApiResponse } from 'next';

interface ApiResponse {
  error?: string;
  data?: any;
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
): Promise {
  if (req.method === 'POST') {
    try {
      const apiKey = process.env.APPROXIMATED_API_KEY;  // Accessing the API key from environment variable
      const primaryDomain = process.env.NEXT_PUBLIC_APP_PRIMARY_DOMAIN; 
      const response = await fetch('https://cloud.approximated.app/api/vhosts', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Accept': 'application/json',
          'Api-Key': apiKey || '' // Using the API key from .env
        },
        body: JSON.stringify({
          incoming_address: req.body.incoming_address,
          target_address: primaryDomain || ''
        })
      });

      const data = await response.json();
      if (response.ok) {
        res.status(200).json(data);
      } else {
        res.status(response.status).json({ error: data || 'Error creating virtual host' });
      }
    } catch (error) {
      res.status(500).json({ error: 'Failed to create virtual host due to an unexpected error' });
    }
  } else {
    res.setHeader('Allow', ['POST']);
    res.status(405).end('Method Not Allowed');
  }
}

In the example above, our endpoint is sending a POST request to the Approximated API with two fields: the custom domain (incoming_address) and the domain of our example app (target_address).

Here's a simple page with a form for adding a custom domain to Approximated, using the endpoint above. It also detects whether we're loading the page from the primary domain, or a custom domain.

// in pages/index.tsx
import type { InferGetServerSidePropsType, GetServerSideProps } from 'next';
import { Inter } from 'next/font/google';
import { useState, FormEvent } from 'react';

const inter = Inter({ subsets: ['latin'] });

type ApproximatedPage = {
  domain: string;
}

export const getServerSideProps = (async ({ req }) => {
  /**
  * Check if there's a header with the custom domain,
  * and if not just use the host header.
  * If you're using approximated.app the default is to
  * inject the header 'apx-incoming-host' with the custom domain.
  */
  const domain = req.headers['apx-incoming-host'] || req.headers.host || process.env.NEXT_PUBLIC_APP_PRIMARY_DOMAIN;

  // do something with the "domain"

  return { props: { domain } }
}) as GetServerSideProps<ApproximatedPage>;

export default function Home({ domain }: InferGetServerSidePropsType<typeof getServerSideProps>) {
  return (
    <main
      className={`flex min-h-screen flex-col items-center gap-8 p-24 ${inter.className}`}
    >
      {domain === process.env.NEXT_PUBLIC_APP_PRIMARY_DOMAIN ? 'Welcome to the primary domain' : `Welcome to the custom domain ${domain}`}
      <DomainForm />
    </main>
  )
};

interface DomainFormData {
  incoming_address: string;
}

const DomainForm: React.FC = () => {
  const [incoming_address, setDomain] = useState<string>('');
  const [errors, setErrors] = useState<object| null>(null); // State to hold response errors
  const [success, setSuccess] = useState<string | null>(null); // State to hold success message
  const [dnsMessage, setDnsMessage] = useState<string | null>(null); // State to hold DNS message

  const handleSubmit = async (event: FormEvent) => {
    event.preventDefault();
    setErrors(null); // Reset errors on new submission
    setSuccess(null); // Reset message on new submission
    setDnsMessage(null); // Reset DNS message on new submission
    const formData: DomainFormData = { incoming_address };

    const response = await fetch('/api/createVirtualHost', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(formData)
    });

    const data = await response.json();
    if (!response.ok) {
      console.log(data);
      // Assuming the error message is in the `message` field
      if(data.error === 'Unauthorized'){
        setErrors({'unauthorized': 'Unauthorized - incorrect or missing Approximated API key.', 'help': 'Check your env variables for APPROXIMATED_API_KEY.'});
      }else{
        setErrors(data.error.errors || data.error || {'unknown': 'An unknown error occurred'});
      }
      return;
    }

    setSuccess(data.data.incoming_address)  // Handle the response as needed
    setDnsMessage(data.data.user_message)  
  };

  return (
    <form className="text-center" onSubmit={handleSubmit}>
      <div className="mb-2">
        <label htmlFor="incoming_address">Connect a custom domain</label>
      </div>
      <input
        type="text"
        id="incoming_address"
        value={incoming_address}
        onChange={(e) => setDomain(e.target.value)}
        required
        className="text-black text-left px-2 py-1 text-xs"
      />
      <button className="ml-2 border border-white rounded-md px-2 py-1 text-xs" type="submit">Submit</button>
        {dnsMessage && <div className="mt-4 mx-auto max-w-xl text-sm mb-2">{dnsMessage}</div>}
        {success && 
          <div className="mt-2 mx-auto max-w-4xl">Once the DNS for the custom domain is set, <a href={'https://'+success} target="_blank" className="text-green-600 underline">Click here to view it.</a>
          </div>
        }
        {errors && Object.entries(errors).map(([key, message]) => (
          <p key={key} className="text-red-500 mt-2">{message}</p>
        ))}
    </form>
  );
}

This is all it takes to fully automate custom domains with Approximated, and have reliable SSL certificate management at any scale!

Between these two files, we're able to submit a custom domain to an API endpoint which then calls the Approximated API. It returns either a successful response with DNS instructions, or display errors such as duplicate custom domains.

You'll likely want to do more than this in a real world scenario, depending on your app. Things like validation, or adding custom domains to a database can be implemented however you see fit. Approximated is agnostic to the rest of your app - it will happily coexist with whatever stack or implementation details you choose now and in the future.