Build a one-click order application with TypeScript and Next.js
Introduction
When you're building an e-commerce application, you want to give customers a great user experience. You also need to make sure that any calls to external services, like databases, payment gateways, and other tools, are reliable.
Next.js is a popular choice for building full-stack web applications using Node.js and React. You can deliver a great experience across the stack by integrating a Temporal Workflow with Next.js. Temporal provides fault tolerance and ensures that long-running processes and background tasks complete successfully, even in the event of failures. This is ideal for critical business operations and transactions.
In this tutorial you'll build a back-end API using Nest API Routes that starts a Temporal Workflow. Then you'll build a quick user interface with React and Tailwind to call that API. When you're done, you'll have a framework you can follow for building full-stack web applications powered by Temporal.
Prerequisites
Before starting this tutorial:
- Set up a local development environment for developing Temporal applications using TypeScript
- Ensure you have a local Temporal Service running, and that you can access the Temporal Web UI from port
8233
. - Review the Hello World in TypeScript tutorial to understand the basics of getting a Temporal TypeScript SDK project up and running.
Create your project
You'll use the Next.js project generator to create a basic Next.js application. Then you'll configure dependencies for the project and set up a Temporal project within the Next.js application.
Create a new Next.js project with the create-next-app
tool. Call the project nextjs-temporal
:
npx create-next-app@latest nextjs-temporal
The project generator will prompt you with several questions. Accept the default values for each option. When the project generator completes, and the dependencies install, switch to the new project's root directory:
cd nextjs-temporal
Install the @tsconfig/node20
package as a developer dependency, as you'll use it as the foundation for a Temporal-specific tsconfig
file you'll keep separate from the one generated by the Next.js project generator:
npm install --save-dev @tsconfig/node20
Next, install Nodemon which you'll use to watch your files for changes and reload Temporal Workers:
npm install --save-dev nodemon
With the dependencies installed, you can add Temporal to the project.
In the root directory of your Next.js project, execute the following command to install Temporal and its dependencies:
npm install @temporalio/client @temporalio/worker @temporalio/workflow @temporalio/activity
Now create a directory to hold your Temporal Workflows and Activities:
mkdir -p temporal/src
Configure TypeScript to compile from temporal/src
to temporal/lib
by adding a new tsconfig.json
in the temporal/src
folder:
touch temporal/tsconfig.json
Add the following configuration to the file:
{
"extends": "@tsconfig/node20/tsconfig.json",
"version": "4.4.2",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"rootDir": "./src",
"outDir": "./lib"
},
"include": ["src/**/*.ts"]
}
For convenience, set up some scripts to run the builds in your project root's package.json
.
Add the npm-run-all
command to your project as a dependency:
npm install npm-run-all --save-dev
Then use npm-run-all
to define scripts to run your Temporal build processes and run the Next.JS app alongside Temporal Workers.
Locate your existing scripts
section, which defines scripts to run and build Next.js applications:
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
Change the scripts so you can build and run your Next.js application and your Temporal Workers at the same time:
"scripts": {
"dev": "npm-run-all -l build:temporal --parallel dev:temporal dev:next start:worker",
"dev:next": "next dev",
"dev:temporal": "tsc --build --watch ./temporal/tsconfig.json",
"build:next": "next build",
"build:temporal": "tsc --build ./temporal/tsconfig.json",
"start:worker": "nodemon ./temporal/lib/worker",
"start": "next start",
"lint": "eslint ."
},
These scripts let you run the following steps with a single npm run dev
command:
- build Temporal once.
- start Next.js locally.
- start a Temporal Worker.
- rebuild Temporal files when they change.
With your project configured, you can write your Temporal Workflows and Activities.
Define the business logic using Temporal
You'll use Temporal to power a one-click ordering process. You'll use a Temporal Workflow to represent each order.
Workflows define the overall flow of your business process. Conceptually, a Workflow is a sequence of steps written in your programming language.
Workflows orchestrate Activities, which is how you interact with the outside world in Temporal. You use Activities to make API requests, access the file system, or perform other non-deterministic operations.
The Workflow you'll create in this tutorial will have a single Activity, purchase
.
Create the file temporal/src/activities.ts
:
touch temporal/src/activities.ts
Inside of temporal/src/activities.ts
, add the following code to define the purchase
function:
- TypeScript
- JavaScript
import { activityInfo } from '@temporalio/activity';
export async function purchase(id: string): Promise<string> {
console.log(`Purchased ${id}!`);
return activityInfo().activityId;
}
import { activityInfo } from '@temporalio/activity';
export async function purchase(id) {
console.log(`Purchased ${id}!`);
return activityInfo().activityId;
}
For this tutorial, the function prints a message to the console and returns the ID of the Activity. In a real application, this function would interact with a payment API and attempt to make a payment. You may also have other Activities that send emails or persist things to databases.
Now define the oneClickBuy
Workflow that calls this Activity. This is the Workflow you'll invoke from the Next.js API.
Create the file temporal/src/workflows.ts
:
touch temporal/src/workflows.ts
Add the following code to temporal/src/workflows.ts
to define the Workflow:
- TypeScript
- JavaScript
import { proxyActivities, sleep } from '@temporalio/workflow';
import type * as activities from './activities';
const { purchase } = proxyActivities<typeof activities>({
startToCloseTimeout: '1 minute',
});
export async function oneClickBuy(id: string): Promise<string> {
const result = await purchase(id); // calling the activity
await sleep('10 seconds'); // sleep to simulate a longer response.
console.log(`Activity ID: ${result} executed!`);
return result;
}
import { proxyActivities, sleep } from '@temporalio/workflow';
const { purchase } = proxyActivities({
startToCloseTimeout: '1 minute',
});
export async function oneClickBuy(id) {
const result = await purchase(id); // calling the activity
await sleep('10 seconds'); // sleep to simulate a longer response.
console.log(`Activity ID: ${result} executed!`);
return result;
}
This Workflow calls the purchase
Activity and then uses await sleep()
to create an artificial delay in the Workflow. A more complex Workflow would call more Activities.
Workflows must be deterministic, so you perform non-deterministic work in Activities.
The TypeScript SDK bundles Workflow code and runs it inside a deterministic sandbox. This sandbox can help detect if you're using nondeterministic code.
This is why you must separate Workflow code from Activity code, and why you have to use the proxyActivities
function to load your Activity functions instead of directly importing them. The Activities will be nondeterministic, so you can't run in the same sandbox as the Workflow code.
With your Workflows and Activities in place, you can now write a Worker Program.
You use a Worker Program to define a Worker which hosts your Activities and Workflows and polls the ecommerce-oneclick
Task Queue to look for work to do.
You'll need to use the Task Queue name in your Worker Program, and any time you interact with a Workflow from your Next.js application. To guarantee you use it consistently, create a constant.
Create the file temporal/src/shared.ts
:
touch temporal/src/shared.ts
Add the following line to the file to define the constant:
- TypeScript
- JavaScript
export const TASK_QUEUE_NAME = 'ecommerce-oneclick';
export const TASK_QUEUE_NAME = 'ecommerce-oneclick';
Now define the Worker program. Create temporal/src/worker.ts
:
touch temporal/src/worker.ts
In temporal/src/worker.ts
, define the Worker Program:
- TypeScript
- JavaScript
import { NativeConnection, Worker } from '@temporalio/worker';
import * as activities from './activities';
import { TASK_QUEUE_NAME } from './shared';
run().catch((err) => console.log(err));
async function run() {
const connection = await NativeConnection.connect({
address: 'localhost:7233',
// In production, pass options to configure TLS and other settings.
});
try {
const worker = await Worker.create({
connection,
workflowsPath: require.resolve('./workflows'),
activities,
taskQueue: TASK_QUEUE_NAME
});
await worker.run();
} finally {
connection.close();
}
}
import { NativeConnection, Worker } from '@temporalio/worker';
import * as activities from './activities';
import { TASK_QUEUE_NAME } from './shared';
run().catch((err) => console.log(err));
async function run() {
const connection = await NativeConnection.connect({
address: 'localhost:7233',
// In production, pass options to configure TLS and other settings.
});
try {
const worker = await Worker.create({
connection,
workflowsPath: require.resolve('./workflows'),
activities,
taskQueue: TASK_QUEUE_NAME
});
await worker.run();
}
finally {
connection.close();
}
}
When the Worker finds Workflow Tasks or Activity Tasks for the Workflows and Activities it hosts, it executes them.
Run your Worker with the following command to make sure that everything builds and there are no errors:
npm run build:temporal && npm run start:worker`
The Worker runs, but it won't have any tasks to perform because you haven't started a Workflow yet.
Next you'll make a Next.js API route that starts a Temporal Workflow.
Define the back-end API
You'll use Next.js API routes to expose a serverless endpoint that your frontend can call and then communicate with Temporal on the back-end.
You don't want to create a new Temporal Client on every API request, so create a function that creates a new Temporal Client or returns the existing one. In temporal/src
add a new file called client.ts
:
touch temporal/src/client.ts
Add the following code to temporal/src/client.ts
to define a makeClient
function that creates the client, and a getTemporalClient
function that retrieves it:
- TypeScript
- JavaScript
import { Client, Connection } from '@temporalio/client';
const client: Client = makeClient();
function makeClient(): Client {
const connection = Connection.lazy({
address: 'localhost:7233',
// In production, pass options to configure TLS and other settings.
});
return new Client({ connection });
}
export function getTemporalClient(): Client {
return client;
}
import { Client, Connection } from '@temporalio/client';
const client = makeClient();
function makeClient() {
const connection = Connection.lazy({
address: 'localhost:7233',
// In production, pass options to configure TLS and other settings.
});
return new Client({ connection });
}
export function getTemporalClient() {
return client;
}
You'll use the getTemporalClient
function in your Next.js application any time you need to interact with Temporal.
Now build out the API route to buy an item. In the root of your application, add a new app/api/startBuy
folder:
mkdir -p app/api/startBuy
Then create the file app/api/startBuy/route.ts
:
touch app/api/startBuy/route.ts
Within the file, create a POST
route that fetches a Temporal Client and uses it to start a Workflow Execution:
- TypeScript
- JavaScript
import { oneClickBuy } from '../../../temporal/src/workflows';
import { getTemporalClient } from '../../../temporal/src/client';
import { TASK_QUEUE_NAME } from '../../../temporal/src/shared';
export async function POST(req: Request) {
interface RequestBody {
itemId: string;
transactionId: string;
}
let body: RequestBody;
try {
body = await req.json() as RequestBody;
} catch (error) {
return new Response("Invalid JSON body", { status: 400 });
}
const { itemId, transactionId } = body;
if (!itemId) {
return new Response("Must send the itemID to buy", { status: 400 });
}
await getTemporalClient().workflow.start(oneClickBuy, {
taskQueue: TASK_QUEUE_NAME,
workflowId: transactionId,
args: [itemId],
});
return Response.json({ ok: true });
}
import { oneClickBuy } from '../../../temporal/src/workflows';
import { getTemporalClient } from '../../../temporal/src/client';
import { TASK_QUEUE_NAME } from '../../../temporal/src/shared';
export async function POST(req) {
let body;
try {
body = await req.json();
}
catch (error) {
return new Response("Invalid JSON body", { status: 400 });
}
const { itemId, transactionId } = body;
if (!itemId) {
return new Response("Must send the itemID to buy", { status: 400 });
}
await getTemporalClient().workflow.start(oneClickBuy, {
taskQueue: TASK_QUEUE_NAME,
workflowId: transactionId,
args: [itemId],
});
return Response.json({ ok: true });
}
The API route checks the JSON input and ensures a product ID exists. If it does, it then uses the Temporal Client to start the Workflow:
workflow.start
sends a request to the Temporal Service to start a Workflow Execution.
The actual Workflow doesn't run until a Worker sees the Workflow Task in the Task Queue and starts working on it.
This API endpoint immediately returns a response, even though the Workflow has a 10-second delay. If you change this to workflow.execute
, the call will block until the Workflow finishes.
That's because workflow.start
resolves when the Temporal Service acknowledges that it's scheduled the Workflow Execution, whereas workflow.execute
resolves only when the Workflow completes.
Make sure you've saved all your changes.
Now start Next.js and the Temporal Worker using the npm run dev
script you defined:
npm run dev
If you receive an error like the following:
[start:worker] TransportError: tonic::transport::Error(Transport, hyper::Error(Connect,
ConnectError("tcp connect error", Os { code: 61,
kind: ConnectionRefused, message: "Connection refused" })))
This means the Temporal Client can't connect to the Temporal Service.
Make sure you have a local Temporal Service running.
Open a separate terminal window and start the service with temporal server start-dev
.
In another terminal window, use the curl
command to make a request to the API endpoint, which starts the Temporal Workflow:
curl -d '{"itemId":"1", "transactionId":"abc123"}' \
-H "Content-Type: application/json" \
-X POST http://localhost:3000/api/startBuy
The terminal that's running your application and Temporal Worker will print Purchased 1
:
[dev:next ] POST /api/startBuy 200 in 16ms
[start:worker] Purchased 1!
Remember, because the API endpoint uses workflow.start
, it's not blocking on the Workflow Execution itself, so you see an almost immediate response from the API.
Now that you know the API can start Temporal Workflows, you can build the user interface.
Build the front-end interface
You can call your Next.js API with curl
, but that's not the user experience you want to present to shoppers.
You'll use React components with Next.js to make a request to the API you just created to create the one-click buy experience.
To call the API Route from the Next.js frontend, you'll use the fetch
API to make a request to the /api/startbuy
route when you click a button.
Build out the front-end user interface. Open app/page.tsx
and remove the contents of the file that the project generator created.
Then at the top, add the following code to add directives and import the necessary libraries:
'use client'
import Head from 'next/head';
import React, { useState, useRef } from 'react';
import { v4 as uuid4 } from 'uuid';
Next, define a TypeScript Interface for the Product's properties, a Type to define states for the purchase, and a collection of Products:
interface ProductProps {
product: {
id: number;
name: string;
price: string;
};
}
const products = [
{
id: 1,
name: 'PDF Book',
price: '$49',
},
{
id: 2,
name: 'Kindle Book',
price: '$49',
},
];
type ITEMSTATE = 'NEW' | 'ORDERING' | 'ORDERED' | 'ERROR';
In a production application, your products would come from another part of your application, such as a database or a Shopify store.
Next, define a Product
component with the following code:
const Product: React.FC<ProductProps> = ({ product }) => {
const itemId = product.id;
const [state, setState] = useState<ITEMSTATE>('NEW');
const [transactionId, setTransactionId] = React.useState(uuid4());
const buyProduct = () => {
setState('ORDERING');
fetch('/api/startBuy', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ itemId, transactionId }),
})
.then(() => {
setState('ORDERED');
})
.catch(() => {
setState('ERROR');
});
};
const buyStyle = "w-full bg-white hover:bg-blue-200 bg-opacity-75 backdrop-filter backdrop-blur py-2 px-4 rounded-md text-sm font-medium text-gray-900 text-center";
const orderingStyle = "w-full bg-yellow-500 bg-opacity-75 backdrop-filter backdrop-blur py-2 px-4 rounded-md text-sm font-medium text-gray-900 text-center";
const orderStyle = "w-full bg-green-500 bg-opacity-75 backdrop-filter backdrop-blur py-2 px-4 rounded-md text-sm font-medium text-gray-900 text-center";
const errorStyle = "w-full bg-white hover:bg-blue-200 bg-opacity-75 backdrop-filter backdrop-blur py-2 px-4 rounded-md text-sm font-medium text-gray-900 text-center";
return (
<div key={product.id} className="relative group">
<div className="mt-4 flex items-center justify-between text-base font-medium text-gray-900 space-x-8">
<h3>{product.name}</h3>
<p>{product.price}</p>
</div>
<div className="aspect-w-4 aspect-h-3 rounded-lg overflow-hidden bg-gray-100">
<div className="flex items-end p-4" aria-hidden="true">
{
{
NEW: ( <button onClick={buyProduct} className={buyStyle}> Buy Now </button> ),
ORDERING: ( <div className={orderingStyle}>Orderering</div> ),
ORDERED: ( <div className={orderStyle}>Ordered</div> ),
ERROR: ( <button onClick={buyProduct} className={errorStyle}>Error! Click to Retry </button> ),
}[state]
}
</div>
</div>
</div>
);
};
In this component, the buyProduct
function makes the call to start the Temporal Workflow by making a request to the API route you defined.
The component renders the product and displays a button for the customer to click.
Based on the order state, the component replaces the button with confirmation message or a new button that lets the customer try again if there's an error. Tailwind styles help control how the buttons and messages look.
Now add a ProductList
component with the following code that renders each Product
component in the list of products:
const ProductList: React.FC = () => {
return (
<div className="bg-white">
<div className="max-w-2xl mx-auto py-16 px-4 sm:py-24 sm:px-6 lg:max-w-7xl lg:px-8">
<div className="mt-6 grid grid-cols-1 gap-x-8 gap-y-8 sm:grid-cols-2 sm:gap-y-10 md:grid-cols-4">
{products.map((product) => (
<Product product={product} key={product.id} />
))}
</div>
</div>
</div>
);
};
Like the Product
component. you're using Tailwind to style the product list.
Finally, add the Home
component to define the overall page structure and render the product list:
const Home: React.FC = () => {
return (
<div className="pt-8 pb-80 sm:pt-12 sm:pb-40 lg:pt-24 lg:pb-48">
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 sm:static">
<Head>
<title>Temporal + Next.js One-Click Purchase</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<header className="relative overflow-hidden">
<div className="sm:max-w-lg">
<h1 className="text-4xl font font-extrabold tracking-tight text-gray-900 sm:text-6xl">
Temporal.io + Next.js One Click Purchase
</h1>
<p className="mt-4 text-xl text-gray-500">
Click on the item to buy it now.
</p>
</div>
</header>
<ProductList />
</div>
</div>
);
};
export default Home;
Ensure you've saved all your work.
Visit http://localhost:3000
in your browser and you'll see two products.
Click their buttons and you'll execute the Temporal Workflows.
The UI state will change almost immediately because the Next.js API route returns immediately, even though you've added an artificial delay to the Workflow.
Log into the local Temporal Web UI running on http://localhost:8233
and you'll see the entire Workflow Execution.
You now have a Next.js application that uses Temporal Workflows to power one of its back-end processes.
When you're moving from your local machine to either Temporal Cloud or a self-hosted Temporal Service, you'll need to configure Temporal Clients and Temporal Workers to communicate with the remote service. To connect to a Temporal Service using mTLS authentication, you will need to configure the connection address, Namespace, and mTLS certificates and keys.
Review Run Workers with Temporal Cloud for the TypeScript SDK for more details on creating certificates and connecting to Temporal Cloud.
In this application, you'd change temporal/src/worker.ts
to add certificate information:
- TypeScript
- JavaScript
const connection = await NativeConnection.connect({
address,
tls: {
clientCertPair: {
crt: fs.readFileSync(clientCertPath),
key: fs.readFileSync(clientKeyPath),
},
},
});
const connection = await NativeConnection.connect({
address,
tls: {
clientCertPair: {
crt: fs.readFileSync(clientCertPath),
key: fs.readFileSync(clientKeyPath),
},
},
});
Then you'd update the makeClient
function in temporal/src/client.ts
to include the same keys:
- TypeScript
- JavaScript
function makeClient(): Client {
const connection = Connection.lazy({
address: 'localhost:7233',
tls: {
clientCertPair: {
crt: fs.readFileSync(clientCertPath),
key: fs.readFileSync(clientKeyPath),
},
},
});
return new Client({ connection });
}
function makeClient() {
const connection = Connection.lazy({
address: 'localhost:7233',
tls: {
clientCertPair: {
crt: fs.readFileSync(clientCertPath),
key: fs.readFileSync(clientKeyPath),
},
},
});
return new Client({ connection });
}
Restart your application to establish the connections to the new Temporal Service.
Conclusion
At this point, you have a working full stack example of a Temporal Workflow running inside your Next.js app, and the beginnings of an order processing system. From here you can add more Activities to the Workflow, or use this project as the basis for a different kind of application that needs long-running processes.
As you build out more features, you may find you need to change the state of an in-progress Workflow, or retrieve information from that Workflow. You can use Signals to send asynchronous data to running Workflows, and you can use Queries to check the state of a Workflow. You can map Signals and Queries to new Next.js API routes using the Temporal Client.
For a more detailed example, look at the Next.js E-Commerce One-Click example in the samples-TypeScript repository.
You can deploy your Next.js app, including Next.js API Routes with Temporal Clients in them, anywhere you can deploy Next.js applications. This includes serverless environments like Vercel or Netlify. However, you must deploy your Temporal Workers in traditional environments, such as EC2, DigitalOcean, or Render. They won't work in a serverless environment.
As you move into production with your app, you'll find the following documentation topics helpful: