Carter Rabasa
May 30, 2023

Table of contents
Creating a Next.js web application
Get Courier API Credentials
Configure Your Email and SMS Providers
Create a Notification Template
Get Vercel KV Credentials
Let’s Start Coding!
New User Page
Create User Route
The User Model
Handling Sessions
Try Creating a User
Forgot Password Page
Send Token Route
Building the Email and SMS Notification Templates
Routing to the Right Channel
Enter Token Page
Verify Token Route
Locking Down Routes with Middleware
New Password Page
Reset Password Route
Wrapping Things Up
When you’re building a web application, there’s an immediate decision to make about how to handle Users and Authentication. There are lots of services (Auth0, Clerk) and libraries (Passport.js) to choose from, and the right choice will depend on the requirements of the application that you’re building. Regardless of the decision here, it’s important that end users have a simple way to reset their passwords and to receive those token notifications on their preferred channel (sms, email, etc).
In this tutorial, I’m going to build a simple and secure password reset flow using Courier and the latest Next.js 13 (app router) that allows the end user to receive a token using either SMS or email. We’re going to cover:
There are a few prerequisites for completing this tutorial:
You can find the full source code for this application on Github and a live demo of this app hosted on Vercel.
In order to build a Next.js app, you’ll need to have Node.js installed. My preference these days is to use NVM (Node Version Manager) to install Node.js. It makes it easy to install multiple versions of Node.js and switch between them in your projects.
Once you’ve installed Node.js, open up a terminal and run the following command to install Next.js:
Copied!
npx create-next-app@latest
You’ll be prompted to answer several questions, but it’s fine to stick to the defaults. Once this process is complete, a new directory will be created and loaded with all of the default files for this app.
Change into this new directory and create a .env.local file to store secrets for Courier and Vercel. We’ll populate this file while we’re building and testing on localhost, and you’ll just need to remember to migrate these environment variables to whatever platform or infra you deploy your app to.
Log-in to your Courier account and click on the gear icon and then API Keys. When you create a Courier account, we automatically create two Workspaces for you, one for testing and one for production. Each workspace has its own set of data and API keys.
For simplicity, we’re going to stick to the “production” workspace. Copy the "published" production API Key and paste into into .env.local using the following key:
Copied!
COURIER_AUTH_TOKEN=pk_XXX
The "published" API key means that when you send a notification and reference a template, it will only use the published version of that template. If you’re editing a template and it is auto-saved as a draft (but not published) you can use the "draft" API key to use that draft template when sending. Once again, for the sake of simplicity, we're going to stick to published templates and the published API key.
Click on "Channels" in the left nav. This is where you can configure the providers you'd like to use to deliver notifications. These providers are grouped into channels, like SMS and email. For the purposes of this tutorial, you need to configure an SMS provider (like Twilio) and an email provider (like Postmark).
Click on "Designer" on the left nav. You'll see a default notification template called "Welcome to Courier". Notification templates make it easy for developers to customize what a single notification (i.e. a password reset notification) looks like across different channels like email, SMS, push, etc.
Create a new template and call it "Password Reset Token". Leave the Subscription Topic set to "General Notifications". The next screen will allow you to select the channels you’d like to design a template for. Please select "email" and "SMS".
On the left, you’ll see a list of the channels you selected. Make sure to configure each channel to use the provider you configured above. If you only have one provider for each channel, Courier will default to it and there’s nothing for you to do. If you had multiple providers for a channel, you’d see a warning and be asked to select one.
In your browser’s URL bar, you should see a URL that looks like this:
https://app.courier.com/designer/notifications/XXX/design?channel=YYY
The XXX is the unique ID of this template. Copy that ID and paste it into your .env.local:
Copied!
COURIER_TEMPLATE=XXX
Log-in to your Vercel account and click on "Storage". Click "Create Database" and select KV (Durable Redis). Give the database any name you like and stick to the default configuration options for now.
Once your new KV database is created, you’ll see a "Quickstart" section just below the name of your database. In that section click the ".env.local" tab. This displays the environment variables you need to interact with the database from your app. Click "copy snippet" and paste those values into your app’s .env.local file:
Copied!
KV_URL="redis://default:xxx@1234.kv.vercel-storage.com:35749"KV_REST_API_URL="https://1234.kv.vercel-storage.com"KV_REST_API_TOKEN="yyy"KV_REST_API_READ_ONLY_TOKEN="zzz"
Ok, now that we have our services and configuration out of the way, let's dive into the code. This app is built on the latest Next.js 13 with the app router. These application will support the following flow:
The new Next.js has strict conventions on how to create routes for client-side and server-side code. To create the new user page, first create a new directory under app called new-user and then create a file in that directory called page.js. Paste the following code into the file:
Copied!
export default function NewUser(request) {return (<main className="flex min-h-screen flex-col items-center justify-between p-24"><p>Hello New User Page</p></main>)}
Spin-up your local dev server to double-check that everything is working properly. In the root of your project run:
Copied!
npm run dev
Then open up http://locahost:3000/new-user in your browser and confirm that you see "Hello New User Page". Once you’ve verified the app is working, we can move on to building out this page.
At the top of the file, including the following:
Copied!
'use client'import { useRouter } from 'next/navigation'import { useState } from 'react'
The use client directive tells Next.js that this component should only run on the client-side. Take a minute to familiarize yourself with React.js client and server components and how they fit into the design of the new Next.js. The useRouter and useState imports give us tools to handle redirection and displaying error messages.
Now, replace the <p>Hello New User Page</p> with the following code:
Copied!
<form onSubmit={onCreateUser} className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">{ error && ( <div className="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">{ error }</div>) }<div className="mb-4"><p>Create a FAKE user so that we can test the password reset flow.</p><p>Please enter your REAL email address and phone number in order to see how the demo works.</p><p>NOTE: all your data will be purged after 5 minutes.</p></div><div className="mb-4"><label htmlFor="name" className="block text-gray-700 text-sm font-bold mb-2">Full Name</label><input type="text" name="name" id="name" className="mb-2 shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"></input><label htmlFor="email" className="block text-gray-700 text-sm font-bold mb-2">Email Address</label><input type="email" name="email" id="email" className="mb-2 shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"></input><label htmlFor="phone" className="block text-gray-700 text-sm font-bold mb-2">Mobile Number</label><input type="text" name="phone" id="phone" className="mb-2 shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"></input><label htmlFor="password" className="block text-gray-700 text-sm font-bold mb-2">Password</label><input type="password" name="password" id="password" className="mb-2 shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"></input><label htmlFor="preference" className="block text-gray-700 text-sm font-bold mb-2">Notification Preference</label><select name="preference" id="preference"><option value="email">Email</option><option value="phone">SMS</option></select></div><input type="submit" value="Create User" className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"></input></form>
Tailwind classes aside, this is a pretty basic HTML form that lets you create a quick and dirty user for the sake of testing the password reset flow. In a real world application, you’ll need to have a proper user management set-up.
The form submission itself triggers a client-side JS function that we will now define. Just below the export default function NewUser(request) { line, add the following code:
Copied!
const router = useRouter()const [ error, setError ] = useState()async function onCreateUser(event) {event.preventDefault()const formData = new FormData(event.target)const payload = {name: formData.get('name'),email: formData.get('email'),phone: formData.get('phone'),password: formData.get('password'),preference: formData.get('preference'),}const response = await createUser(payload)if (response.error) {setError(response.error)}else if (response.redirect) {router.push(`${response.redirect}?message=${response.message}`)}return true}
The router allows us to trigger client-side routing when it's time to move on to the next page. The error and setError hooks allow us to display error messages to the user.
The function onCreateUser does the work of parsing the HTML form, building a JSON payload, calling the createUser and then either redirecting (success) or displaying an error (failure).
Finally, below the imports at the top of the file, include the following function:
Copied!
// submit this data to create-user/route.jsasync function createUser(payload) {const res = await fetch('/create-user', { method: 'POST', body: JSON.stringify(payload) })if (!res.ok) return undefinedreturn res.json()}
This function uses JS native fetch to call our backend to create the user. Go ahead and reload http://localhost:3000/new-user and ensure that the form renders properly. But don’t submit it! We haven’t written the backend, so let’s do that now.
Create a new directory under app called create-user and create a file called route.js. Route handlers are used for backend code and can support GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS HTTP methods. In our case we’re going to implement a POST handler with the following code:
Copied!
import { NextResponse } from 'next/server'import { kv } from '@vercel/kv'import { CourierClient } from '@trycourier/courier'import { createUser } from '../../models/users'import { setSession } from '../../session'const courier = CourierClient({ authorizationToken: process.env.courier_auth_token })export async function POST(request) {const data = await request.json()// get full name, phone number, email and password from form payloadconst { name, email, phone, password, preference } = data// create the Userconst user_id = await createUser({ name, email, phone, password, preference })// create the Courier Profile for this Userawait courier.mergeProfile({recipientId: user_id,profile: {phone_number: phone,email,name,// Courier supports storing custom JSON data for Profilescustom: {preference}}})// return responseconst response = NextResponse.json({redirect: '/',message: 'Your User has been created 👍'})setSession(response, 'user_id', user_id)return response}
This function takes the data passed-in and uses it to create a new User. Once you’ve created the User and have a unique user_id you can create a profile in Courier to store this information. Storing a subset of a user's profile information in Courier makes it easy to customize notifications and respect a user's routing preferences.
After the User has been created, a response is sent to the client with information about where to redirect the user to and what message to display. In this case, we're simply going back to the index page.
Before we can execute this route, we need to implement a simple user model and session service. Remember, this code is just for demonstration purposes, so make sure you’re handling Users and Sessions properly when you build your app.
Create a directory at the root of your project called models and create a new file called users.js and paste in the following code:
Copied!
import { kv } from '@vercel/kv'import { createHash } from 'node:crypto'async function createUser({ password, name, email, phone, preference }) {// create unique ID for userconst id = createHash('sha3-256').update(phone ? phone : email).digest('hex')const key = `users:${ id }:${ email }:${ phone }`const ex = 5 * 60 // expire this record in 5 minutes// hash the passwordconst hashed_password = createHash('sha3-256').update(password).digest('hex')await kv.set(key, { user_id: key, hashed_password, name, email, phone, preference }, { ex })return key}async function findUserById(user_id) {const keys = await kv.keys('users:*')const key = keys.find(k => k.indexOf(user_id) >= 0)return key ? await kv.get(key) : null}async function findUserByEmail(email) {const keys = await kv.keys('users:*')const key = keys.find(k => k.indexOf(email) >= 0)return key ? await kv.get(key) : null}async function findUserByPhone(phone) {const keys = await kv.keys('users:*')const key = keys.find(k => k.indexOf(phone) >= 0)return key ? await kv.get(key) : null}async function updatePassword(key, password) {const user = await kv.get(key)const ex = 5 * 60 // expire this record in 5 minutes// hash the passwordconst hashed_password = createHash('sha3-256').update(password).digest('hex')await kv.set(key, { ...user, hashed_password }, { ex })}export {createUser,findUserById,findUserByEmail,findUserByPhone,updatePassword}
Please note that the code above auto-deletes user information after 5 minutes. This is a (not so gentle) reminder that this is not designed for production.
Create a file at the root of your project called session.js and paste the following code:
Copied!
function getSession(req, attr) {const cookie = req.cookies.get(attr)return (cookie ? cookie.value : undefined)}function setSession(res, attr, value) {res.cookies.set(attr, value)}export {getSession,setSession}
This code uses cookies to simulate a session. Once again, do not use this in production.
Ok, we've done a lot of work to wire up Courier, Vercel and our Next.js application. Let’s see if you can create a User! Go to http://localhost:3000/new-user, fill-out the form and click submit. You should be redirected to the index page.
Now, go back to your Courier account and click on "users" in the left nav. If all went well, you should see the new User you created!
With that out of the way, we can finally get to what you came here for: password reset notifications 📬!
Create a directory under app called forgot-password, create a new file in it called page.js and paste the following code:
Copied!
'use client'import { useRouter } from 'next/navigation'import { useState } from 'react'async function sendToken(payload) {const res = await fetch('/send-token', { method: 'POST', body: JSON.stringify(payload) })if (!res.ok) return undefinedreturn res.json()}export default function ForgotPassword(request) {const router = useRouter()const [ error, setError ] = useState()async function onForgotPassword(event) {event.preventDefault()const formData = new FormData(event.target)const payload = {email: formData.get('email'),phone: formData.get('phone')}const response = await sendToken(payload)if (response.error) {setError(response.error)}else if (response.redirect) {router.push(`${response.redirect}?mode=${response.mode}`)}return true}return (<main className="flex min-h-screen flex-col items-center justify-between p-24"><form method="post" onSubmit={ onForgotPassword } className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">{ error && ( <div className="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">{ error }</div>) }<div className="mb-4">Please use the same email or phone number that you used to <a href="/new-user">create your user</a>.</div><div className="mb-4"><label htmlFor="email" className="block text-gray-700 text-sm font-bold mb-2">Email Address</label><input type="email" name="email" id="email" className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"></input></div><div className="mb-4">- or -</div><div className="mb-4"><label htmlFor="phone" className="block text-gray-700 text-sm font-bold mb-2">Mobile Phone</label><input type="text" name="phone" id="phone" className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"></input></div><input type="submit" value="Reset Password" className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"></input></form></main>)}
This code is functionality identical to the code we used for the new-user page. The more interesting code is on the backend.
Create a new directory under app called send-token, create a file called route.js and paste the following code:
Copied!
import { NextResponse } from 'next/server'import { kv } from '@vercel/kv'import { CourierClient } from '@trycourier/courier'import { findUserByEmail, findUserByPhone } from '../../models/users'import { setSession } from '../../session'const courier = CourierClient({ authorizationToken: process.env.courier_auth_token })export async function POST(request) {// get phone number and email from form payloadconst data = await request.json()const { email, phone } = datalet user// look up the user based on phone or emailif (email) {user = await findUserByEmail(email)}else if (phone) {user = await findUserByPhone(phone)}else {// neither an email nor phone number was submitted, re-direct and display errorreturn NextResponse.json({error: 'You must provide an email or phone number'})}if (user) {const { user_id } = user// generate reset tokenconst token = Math.floor(Math.random() * 1000000).toString().padStart(6, '0')const ex = 5 * 60 // expire this record in 5 minutes// store in KV cacheawait kv.set(`${user_id}:reset`, token, { ex })// send notificationawait courier.send({message: {to: {user_id},template: process.env.COURIER_TEMPLATE,data: {token}}})// redirect to enter token pagereturn NextResponse.json({redirect: '/enter-token',mode: (email ? 'email' : 'phone')})}else {// redirect and display errorreturn NextResponse.json({error: 'We could not locate a user with that email address or phone number'})}}
This function uses the user model to retrieve a user based on either an email address or a phone number. A random 6 digit token is generated, stored in Vercel KV and sent to the user for verification using Courier.
Vercel KV is a durable Redis store that's great for the use case of storing tokens and auto-purging them after a few minutes. The code in our function sets a key (who’s value is the user_id appended with :reset) with the value of the token. The ex attribute specifies when the key/value should be automatically purged from the DB.
Let's take a sec to break down this Courier send API call. The top-level attribute that we pass in this API call is a message object. The message object supports several properties that you can use to send and route messages, but in this case we only use 3:
to - required, specifies the recipient(s) of the messagetemplate - the template to use for this messagedata - an arbitrary JSON payload of dynamic dataSince the user's email address and phone number have already been stored in the user's Courier Profile, we only need to pass a user_id in the to portion of the message. Courier will figure out whether to use the email address or phone number based on the user's preference that we stored in custom.preference.
The data attribute is where we store the token that we've generated. Values in data are interpolated into the templates that you define when the message is being rendered. Let’s switch out of the code (briefly!) to define our SMS and email notification templates.
Go back to the Courier App, and click “Designer” on the left nav. Edit the “Password Reset Token” template that you created.
For the email template, set the Subject to "Your password reset token" and add a Markdown Block to the body of the message with the following content:
Copied!
Hello {profile.name}, here is your {token}
You should see {profile.name} and {token} be highlighted in green. This means that Courier is recognizing them as variables.
Since we set the name attribute when creating the profile, it’s magically available to us in the template. Cool! Also, since we passed a token attribute in the send API call, that is also available to us here in this template.
Click on SMS on the left to edit the SMS template. Create a Markdown Block in the body of the message and type out the following:
Copied!
Hi there {profile.name} 👋 Your password reset token is: {token}
Now that we have the template content defined, we need to ensure the messages route to the correct channel based on the user's preference.
Hover your mouse over "email" on the left, and you'll see a gear icon appear. Click the gear icon to edit this channel's settings. Click "conditions" on the left and "add" a new condition.
We are going to "disable" this channel when the profile.preference property is equal to "phone":
Once you're done entering info, everything is auto-saved. Just click outside of the modal to close it. Repeat the same process for the "sms" channel, but set the value for the conditional to "email". We have now disabled these channels in the event the user has selected a different one to receive their notifications on.
Click “publish” in the top right corner to make make these changes live.
Ok, back to the code! Create a new directory in app called enter-token and a file in it called page.js. The user is redirected to this page and must enter the token they are sent via email or SMS in order to proceed. Paste this code into the file:
Copied!
'use client'import { useRouter } from 'next/navigation'import { useState } from 'react'async function verifyToken(payload) {const res = await fetch('/verify-token', { method: 'POST', body: JSON.stringify(payload) })if (!res.ok) return undefinedreturn res.json()}export default function EnterToken(request) {const router = useRouter()const [ error, setError ] = useState()async function onVerifyToken(event) {event.preventDefault()const formData = new FormData(event.target)const payload = {token: formData.get('token'),}const response = await verifyToken(payload)if (response.error) {setError(response.error)}else if (response.redirect) {router.push(response.redirect)}return true}const mode = request.searchParams?.modereturn (<main className="flex min-h-screen flex-col items-center justify-between p-24"><form method="post" onSubmit={ onVerifyToken } className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">{ error && ( <div className="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">{ error }</div>) }<div className="mb-4">Check your { mode } and enter token that we have sent you below. </div><div className="mb-4"><label htmlFor="token" className="block text-gray-700 text-sm font-bold mb-2">Token</label><input type="token" name="token" id="token" className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"></input></div><input type="submit" value="Validate Token" className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"></input></form></main>)}
Nothing to see here, let’s check out the server-side route that handles this form.
Create a directory in app called verify-token and a file in it called route.js with the following code:
Copied!
import { NextResponse } from 'next/server'import { kv } from '@vercel/kv'import User from '../../models/users'import { getSession, setSession } from '../../session'export async function POST(request) {// get phone number and email from form payloadconst data = await request.json()const { token } = data// get user_id from sessionconst userId = getSession(request, 'user_id')const storedToken = "" + await kv.get(`${userId}:reset`) // ensure the token is of type stringif (userId && token && (token === storedToken)) {// redirect to reset password pageconst response = NextResponse.json({redirect: '/new-password'})setSession(response, 'authenticated', true)return response}else {// redirect and display errorreturn NextResponse.json({error: 'Token did not match, please try again?'})}}
Here, we get the token from the form submission and the user_id from the session. We use the user_id to construct the key, and use the key to retrieve the value stored there. We then check to see if the values of the form submission and stored tokens match. If they don't match, we return an error to the page.
If they DO match, we set a property on our session of authenticated to the value of true and forward to the /new-password page.
The last page we are going to build (new-password) and the last route we are going to build (update-password) should be considered secure. We don’t want users to interact with these pages unless they have authenticated themselves by successfully confirming they have received the token we sent them. A recommended way to secure pages and routes in Next.js is by using middleware.
Create a new file in the root of your project called middleware.js and paste the following code:
Copied!
import { NextResponse } from 'next/server'import { getSession } from './session'export function middleware(request) {const authenticated = getSession(request, 'authenticated')if (authenticated) {return NextResponse.next()}else {const homeUrl = new URL('/', request.url)homeUrl.searchParams.set('message', 'You are not authorized')return NextResponse.redirect(homeUrl)}}export const config = {matcher: ['/new-password', '/reset-password'],}
This middleware function processes every request that matches /new-password or /reset-password. For matching requests, the middleware checks the session to see if the user is authenticated. If so, it proceeds with the request. If not, it redirects to the index page with an error message.
Create a directory in app called new-password and a file in it called page.js with the following code:
Copied!
'use client'import { useRouter } from 'next/navigation'import { useState } from 'react'async function resetPassword(payload) {const res = await fetch('/reset-password', { method: 'POST', body: JSON.stringify(payload) })if (!res.ok) return undefinedreturn res.json()}export default function NewPassword(request) {const router = useRouter()const [ error, setError ] = useState()async function onResetPassword(event) {event.preventDefault()const formData = new FormData(event.target)const payload = {newPassword: formData.get('new_password'),newPasswordConfirm: formData.get('new_password_confirm'),}const response = await resetPassword(payload)if (response.error) {setError(response.error)}else if (response.redirect) {router.push(`${response.redirect}?message=${response.message}`)}return true}return (<main className="flex min-h-screen flex-col items-center justify-between p-24"><form method="post" onSubmit={ onResetPassword } className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">{ error && ( <div className="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">{ error }</div>) }<div className="mb-4">Almost done! Now just enter a new password.</div><div className="mb-4"><label htmlFor="new_password" className="block text-gray-700 text-sm font-bold mb-2">New Password</label><input type="password" name="new_password" id="new_password" className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"></input></div><div className="mb-4"><label htmlFor="new_password_confirm" className="block text-gray-700 text-sm font-bold mb-2">Confirm Password</label><input type="password" name="new_password_confirm" id="new_password_confirm" className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"></input></div><input type="submit" value="Reset Password" className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"></input></form></main>)}
Once again, a pretty standard page with a form. Let’s look into the route that processes the new passwords.
Create a directory in app called reset-password and a file in it called route.js with the following code:
Copied!
import { NextResponse } from 'next/server'import { kv } from '@vercel/kv'import { updatePassword } from '../../models/users'import { getSession } from '../../session'export async function POST(request) {// get passwords from payloadconst data = await request.json()const { newPassword, newPasswordConfirm } = data// get user_id from sessionconst user_id = getSession(request, 'user_id')// update the userif (user_id && newPassword && newPasswordConfirm && (newPassword === newPasswordConfirm)) {await updatePassword(user_id, newPassword)return NextResponse.json({redirect: '/',message: 'Your password has been reset 👍'})}else {// password don't matchreturn NextResponse.json({error: 'Your passwords must match'})}}
Here we get the new password and the confirmed password from the form and the user_id from the session. If we have a user_id and the password match, update the user’s password! Woo hoo, we did it!
Phew, we made it! Our goal was to use Courier and Next.js to build a secure password reset flow that allowed the user to receive either an SMS or an email based on their preferences. Let’s review what we covered in this tutorial:
I hope you enjoyed this tutorial and you can ping me (crtr0) on Twitter if you have any questions!
You can find the full source code for this application on Github. Pull requests welcome! You can also play with a live demo of this app which is hosted on Vercel.

Transactional, Product, and Marketing Notifications: What Are the Differences?
Understanding the difference between transactional, product, and marketing notifications is essential for developers building notification infrastructure. Transactional notifications confirm user actions and require no opt-in. Product notifications drive feature adoption through education. Marketing notifications promote sales and require explicit consent. This guide explains the legal requirements, best practices, and when to use each notification type to build compliant systems users trust.Retry
By Kyle Seyler
October 23, 2025

How to Add Toast Notifications with the New Courier Toasts SDK
Learn how to add real-time, customizable toast notifications to your app with the Courier Toasts SDK. This quick tutorial shows how to integrate toasts using Web Components or React and sync them with your notification center for a seamless, modern UX.
By Dana Silver
October 20, 2025

What is the Twilio Messaging API?
Twilio's Messaging API enables developers to send and receive SMS, MMS, WhatsApp, and RCS messages at scale across 180+ countries. While Twilio excels at reliable message delivery through carrier networks, modern applications need more than single-channel messaging. Courier acts as a provider-agnostic orchestration layer that activates messaging across Twilio and other channels from a single platform. You get intelligent routing, user preference management, and fallback logic without vendor lock-in.
By Kyle Seyler
October 03, 2025
© 2026 Courier. All rights reserved.