
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.
In this tutorial you'll build a back-end API using Next API Routes that starts a Temporal Workflow. Then you'll build a quick user interface with React and Tailwind to call that API.
Prerequisites
- 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 Run your first Temporal application with the TypeScript SDK tutorial to understand the basics.
Create your project
Create a new Next.js project with create-next-app. Call the project nextjs-temporal:
npx create-next-app@latest nextjs-temporal
Accept the default values for each option. When dependencies install, switch to the new project's root directory:
cd nextjs-temporal
Install @tsconfig/node20 as a developer dependency:
npm install --save-dev @tsconfig/node20
Install Nodemon:
npm install --save-dev nodemon
Install Temporal and its dependencies:
npm install @temporalio/client @temporalio/worker @temporalio/workflow @temporalio/activity
Create a directory to hold 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:
{
"extends": "@tsconfig/node20/tsconfig.json",
"version": "4.4.2",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"rootDir": "./src",
"outDir": "./lib"
},
"include": ["src/**/*.ts"]
}
Set up scripts. Add npm-run-all:
npm install npm-run-all --save-dev
Locate your existing scripts section:
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
Change to:
"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 with a single npm run dev command:
- build Temporal once.
- start Next.js locally.
- start a Temporal Worker.
- rebuild Temporal files when they change.
Define the business logic using Temporal
You'll use a Temporal Workflow to represent each order. Workflows orchestrate Activities, which is how you interact with the outside world in Temporal. The Workflow you'll create will have a single Activity, purchase.
Create temporal/src/activities.ts:
touch temporal/src/activities.ts
import { activityInfo } from '@temporalio/activity';
export async function purchase(id: string): Promise<string> {
console.log(`Purchased ${id}!`);
return activityInfo().activityId;
}
The function prints a message and returns the Activity ID. In a real application, this would interact with a payment API.
Define the oneClickBuy Workflow. Create temporal/src/workflows.ts:
touch temporal/src/workflows.ts
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;
}
This Workflow calls the purchase Activity and then uses await sleep() to create an artificial delay.
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.
Create a shared constant. Create temporal/src/shared.ts:
touch temporal/src/shared.ts
export const TASK_QUEUE_NAME = 'ecommerce-oneclick';
Define the Worker program. Create temporal/src/worker.ts:
touch temporal/src/worker.ts
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();
}
}
Run your Worker:
npm run build:temporal && npm run start:worker
The Worker runs but won't have any tasks to perform because you haven't started a Workflow yet.
Define the back-end API
You'll use Next.js API routes to expose a serverless endpoint. Create a function that creates a new Temporal Client or returns the existing one. Add temporal/src/client.ts:
touch temporal/src/client.ts
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;
}
Build out the API route. Add a new app/api/startBuy folder:
mkdir -p app/api/startBuy
touch app/api/startBuy/route.ts
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 });
}
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. 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.
Start Next.js and the Temporal Worker:
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. Open a separate terminal window and start the service with temporal server start-dev.
In another terminal, use curl to make a request:
curl -d '{"itemId":"1", "transactionId":"abc123"}' \
-H "Content-Type: application/json" \
-X POST http://localhost:3000/api/startBuy
You'll see:
[dev:next ] POST /api/startBuy 200 in 16ms
[start:worker] Purchased 1!
Build the front-end interface
Use React components with Next.js to make a request to the API you created. To call the API Route from the frontend, use the fetch API to make a request to /api/startbuy when a button is clicked.
Open app/page.tsx and remove the generated contents. At the top, add directives and imports:
'use client'
import Head from 'next/head';
import React, { useState, useRef } from 'react';
import { v4 as uuid4 } from 'uuid';
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';
Define a Product component:
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>
);
};
The buyProduct function makes the call to start the Temporal Workflow. Based on the order state, the component renders different controls. Tailwind styles control the appearance.
Add a ProductList component:
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>
);
};
Add the Home component:
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;
Visit http://localhost:3000 in your browser and you'll see two products. Click their buttons to execute the Temporal Workflows. Log into the local Temporal Web UI running on http://localhost:8233 to see the entire Workflow Execution.
When moving from your local machine to either Temporal Cloud or a self-hosted Temporal Service, configure Temporal Clients and Workers to communicate with the remote service using mTLS. Change temporal/src/worker.ts to add certificate information:
const connection = await NativeConnection.connect({
address,
tls: {
clientCertPair: {
crt: fs.readFileSync(clientCertPath),
key: fs.readFileSync(clientKeyPath),
},
},
});
Update the makeClient function in temporal/src/client.ts:
function makeClient(): Client {
const connection = Connection.lazy({
address: 'localhost:7233',
tls: {
clientCertPair: {
crt: fs.readFileSync(clientCertPath),
key: fs.readFileSync(clientKeyPath),
},
},
});
return new Client({ connection });
}
Conclusion
You have a working full stack example of a Temporal Workflow running inside your Next.js app. From here you can add more Activities, or use this project as the basis for a different application that needs long-running processes.
You can use Signals to send asynchronous data to running Workflows, and Queries to check the state of a Workflow.
For a more detailed example, see 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. However, you must deploy your Temporal Workers in traditional environments, such as EC2, DigitalOcean, or Render. They won't work in a serverless environment.
Get notified when we launch new educational content
New courses, tutorials, and learning resources - straight to your inbox.