monolayer Docs
monolayer Docs
Introduction

Getting Started

Install monolayer in your AWSAdd a GitHub App
Prerequisites and SetupImplementationDeploy the Application

User Guide

Platform

Deployment ArchitectureProcfile

Other

Feedbackmonolayer SDK Docsmonolayer.dev
Your first app

Implementation

Now that the initial environment and UI shell are prepared, let's implement each of the application features step-by-step.

Implementing Todos

Adding a Todos model

We'll define the Prisma schema for Todos in lib/prisma/models/todos.prisma

lib/prisma/models/todos.prisma
model Todos {
  id        String    @id @default(uuid())
  text      String
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt
}

Migrate the database

Generate and run the migration to create the table with:

npm run db:migrate -- --name add-todos

Add Todo list components

Now that your database is ready, let's bring the Todos feature to life in the UI. We'll build a small, self-contained set of React components that display existing todos and let users add new ones.

actions/todos.ts
"use server";

import prisma from "@/lib/prisma/prisma";
import { revalidatePath } from "next/cache";

export type AddTodoState = {
	input: string;
	error?: string;
};

export async function addTodo(
	_: unknown,
	formData: FormData,
): Promise<AddTodoState> {
	const text = formData.get("todo") as string | undefined;

	if (text?.trim()) {
		await prisma.todos.create({
			data: { text, id: formData.get("optimisticId")?.toString() ?? undefined },
		});
		revalidatePath("/");
		return { input: "", error: undefined };
	}

	return {
		error: "Todo cannot be empty",
		input: text ?? "",
	};
}

export type DeleteTodoState = {
	error?: string;
	success?: boolean;
};

export async function deleteTodo(formData: FormData): Promise<DeleteTodoState> {
	const todoId = formData.get("todoId") as string | undefined;

	if (todoId) {
		try {
			await prisma.todos.delete({ where: { id: todoId } });
			revalidatePath("/");
			return { success: true };
		} catch {
			return { success: false, error: "Could not delete todo" };
		}
	}
	return { success: false, error: "Could not delete todo" };
}
components/todo-list/add.tsx
"use client";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Field, FieldError } from "@/components/ui/field";
import { useAddTodoActionState } from "./hooks";

export function AddTodo() {
	const [state, action, isPending] = useAddTodoActionState();

	return (
		<>
			<form action={action} className="flex flex-col py-6 gap-2">
				<div className="flex flex-row grow gap-4">
					<Field data-invalid={state.error !== undefined}>
						<Input
							name="todo"
							defaultValue={state.input}
							placeholder="Add a new todo"
							autoComplete="off"
						/>
					</Field>
					<Button
						variant="outline"
						disabled={isPending}
						className="hover:cursor-pointer"
					>
						Add
					</Button>
				</div>
				<Field data-invalid={state.error !== undefined}>
					<FieldError>{state.error}</FieldError>
				</Field>
			</form>
		</>
	);
}
components/todo-list/delete.tsx
"use client";

import { Button } from "../ui/button";
import { Trash2 } from "lucide-react";
import type { Todos } from "@/lib/prisma/generated/client";
import { useDeleteTodoAction } from "./hooks";

export function DeleteButton({ todo }: { todo: Todos }) {
	const action = useDeleteTodoAction(todo);
	return (
		<form action={action}>
			<input type="hidden" name="todoId" value={todo.id} />
			<Button
				variant="outline"
				size="icon"
				className="hover:cursor-pointer"
				disabled={todo.id === "unsaved"}
			>
				<Trash2 className="text-red-300" />
			</Button>
		</form>
	);
}
components/todo-list/hooks.tsx
import { TodosContext } from "./provider";
import { toast } from "sonner";
import { addTodo, deleteTodo, type AddTodoState } from "@/actions/todos";
import { use, useActionState } from "react";
import { v4 } from "uuid";
import type { Todos } from "@/lib/prisma/generated/client";

export function useTodos() {
	const context = use(TodosContext);
	if (!context) {
		throw new Error("useLogViewer must be used within a <TodosProvider />");
	}

	return {
		todos: context.todos,
		addOptimistic: context.addOptimistic,
	};
}

export function useAddTodoActionState() {
	const { addOptimistic } = useTodos();

	const add = async (_: unknown, formData: FormData): Promise<AddTodoState> => {
		const todo = formData.get("todo")?.toString();
		const optimisticId = v4();
		if (todo) {
			const now = new Date();
			addOptimistic({
				kind: "add",
				todo: {
					id: optimisticId,
					text: todo,
					createdAt: now,
					updatedAt: now,
				},
			});
		}
		formData.set("optimisticId", optimisticId);
		return addTodo(_, formData);
	};

	return useActionState(add, {
		input: "",
	});
}

export function useDeleteTodoAction(todo: Todos) {
	const { addOptimistic } = useTodos();

	return async (form: FormData) => {
		addOptimistic({ kind: "remove", todoId: todo.id });
		const response = await deleteTodo(form);
		if (response.error) toast.error(response.error);
	};
}
components/todo-list/provider.tsx
"use client";

import type { Todos } from "@/lib/prisma/generated/client";
import { createContext, use, useOptimistic } from "react";

type OptimisticAction =
	| { kind: "add"; todo: Todos }
	| { kind: "remove"; todoId: Todos["id"] };

export const TodosContext = createContext<{
	todos: Todos[];
	addOptimistic: (action: OptimisticAction) => void;
} | null>(null);

export default function TodosProvider({
	children,
	todos: initial,
}: React.PropsWithChildren & { todos: Promise<Todos[]> }) {
	const [todos, addOptimistic] = useOptimistic(
		use(initial),
		(currentState: Todos[], action: OptimisticAction) => {
			switch (action.kind) {
				case "add":
					return currentState.concat(action.todo);
				case "remove":
					return currentState.filter((todo) => todo.id !== action.todoId);
			}
		},
	);

	return (
		<TodosContext
			value={{
				todos,
				addOptimistic,
			}}
		>
			{children}
		</TodosContext>
	);
}
components/todo-list/todos.tsx
"use client";

import { Item, ItemActions, ItemContent } from "@/components/ui/item";
import { DeleteButton } from "./delete";
import { useTodos } from "./hooks";

export function Todos() {
	const { todos } = useTodos();
	return (
		<ul className="space-y-2">
			{todos.map((todo) => (
				<Item asChild key={todo.id}>
					<li>
						<ItemContent>
							<span className="text-gray-200 text-sm line-clamp-1">
								{todo.text}
							</span>
						</ItemContent>
						<ItemActions>
							<DeleteButton todo={todo} />
						</ItemActions>
					</li>
				</Item>
			))}
		</ul>
	);
}
lib/todos.ts
import { cache } from "react";
import prisma from "./prisma/prisma";

export const allTodos = cache(async () => await prisma.todos.findMany());

Show todo list in home

Now that we've built the components, it's time to connect them to the main page.

app/page.tsx
import { AddTodo } from "@/components/todo-list/add"; 
import TodosProvider from "@/components/todo-list/provider"; 
import { Todos } from "@/components/todo-list/todos"; 
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { allTodos } from "@/lib/todos"; 

export const dynamic = "force-dynamic"; 

export default function Home() {
	return (
		<TodosProvider todos={allTodos()}>
			<main className="min-h-screen dark text-primary bg-slate-950 p-6">
				<div className="flex flex-col gap-10">
					<h1 className="text-2xl font-bold text-center">monolayer Starter</h1>
					<Tabs defaultValue="todos" className="items-center w-full">
						<TabsList className="w-2xs">
							<TabsTrigger value="todos">Todos</TabsTrigger>
							<TabsTrigger value="documents">Documents</TabsTrigger>
							<TabsTrigger value="reports">Reports</TabsTrigger>
						</TabsList>
						<TabsContent value="todos" className="w-full align-start">
							<div className="py-4 max-w-2xl mx-auto">Todos placeholder</div>
							<div className="py-4 max-w-2xl mx-auto">
								<AddTodo />
								<Todos />
							</div>
						</TabsContent>
						<TabsContent value="documents" className="w-full align-start">
							<div className="py-4 max-w-2xl mx-auto">
								Documents placeholder
							</div>
						</TabsContent>
						<TabsContent value="reports" className="w-full align-start">
							<div className="py-4 max-w-2xl mx-auto">Reports placeholder</div>
						</TabsContent>
					</Tabs>
				</div>
			</main>
		</TodosProvider> 
	);
}

At this stage, the UI should display your list of todos (or an empty state if there are none yet) and the form to add new ones.

Empty Todos

Todos With Todos


Building the Documents Feature

The next tab in our app is Documents — a lightweight document management view where users can upload, list, and delete files.

You'll build:

  • A drop zone to upload files.
  • A documents list showing each file.
  • Download and delete actions for managing them.

Generate a bucket workload

The monolayer SDK lets you define storage bucket workloads right in your project. Run the following command to generate a new bucket workload, boilerplate helper code, and required dependencies.

npx monolayer add bucket --name documents

Start local environment for bucket

Run the following command to spin up a compatible S3 service on your local machine.

npx monolayer start dev

You should see this:

Dev Env With Buckets

Add Components and Server Actions

Next, you'll connect the bucket to the UI. We'll add a few server actions, and then build the React components that use them.

lib/documents.ts
import documents from "@/workloads/documents";
import { paginateBucketItems } from "@/lib/bucket/paginate-bucket-items";
import { cache } from "react";

async function _allDocuments() {
	const items: { key: string }[] = [];
	for await (const item of paginateBucketItems(documents)) {
		if (item.Key)
			items.push({
				key: item.Key,
			});
	}
	return items;
}

export const allDocuments = cache(_allDocuments);
actions/documents.ts
"use server";

import { s3Client } from "@/lib/bucket/client";
import documents from "@/workloads/documents";
import { DeleteObjectCommand } from "@aws-sdk/client-s3";
import { revalidatePath } from "next/cache";

export async function deleteDocument(key: string) {
	const command = new DeleteObjectCommand({
		Bucket: documents.name,
		Key: key,
	});
	await s3Client.send(command);
	revalidatePath("/");
}
actions/revalidate.ts
"use server";

import { revalidatePath } from "next/cache";

export async function revalidate() {
	revalidatePath("/");
}
components/documents/delete.tsx
"use client";

import { Trash2 } from "lucide-react";
import { Button } from "../ui/button";
import type { BucketItem } from "./provider";
import { deleteDocument } from "@/actions/documents";
import { startTransition } from "react";

export function Delete({ item }: { item: BucketItem }) {
	return (
		<Button
			size="icon-sm"
			variant="outline"
			aria-label="Delete document"
			className="hover:cursor-pointer text-red-300 hover:text-red-400"
			disabled={item.optimistic === true}
			onClick={() => {
				startTransition(async () => {
					await deleteDocument(item.key);
				});
			}}
		>
			<Trash2 />
		</Button>
	);
}
components/documents/download.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { Button } from "../ui/button";
import { DownloadIcon } from "lucide-react";
import { presignedDownloadUrl } from "@/lib/bucket/presign";
import documents from "@/workloads/documents";
import type { BucketItem } from "./provider";

export function Download({ item }: { item: BucketItem }) {
	const [href, setHref] = useState<string | undefined>(undefined);
	const anchorRef = useRef<HTMLAnchorElement>(null);

	useEffect(() => {
		if (href && anchorRef.current) {
			anchorRef.current.click();
		}
	}, [href, anchorRef]);

	return (
		<Button
			size="icon-sm"
			variant="outline"
			aria-label="Download document"
			className="hover:cursor-pointer"
			disabled={item.optimistic}
			asChild={!item.optimistic}
			onClickCapture={async (event) => {
				event.stopPropagation();
				setHref(await presignedDownloadUrl(documents.name, item.key));
			}}
		>
			<a href={href} ref={anchorRef} download={true}>
				<DownloadIcon />
			</a>
		</Button>
	);
}
components/documents/hooks.tsx
import { use } from "react";
import { DocumentsContext } from "./provider";

export function useDocuments() {
	const context = use(DocumentsContext);
	if (!context) {
		throw new Error("useLogViewer must be used within a <DocumentsProvider />");
	}

	return {
		documents: context.documents,
		addOptimistic: context.addOptimistic,
	};
}
components/documents/index.tsx
"use client";

import { Item, ItemActions, ItemContent } from "../ui/item";
import { Delete } from "./delete";
import { Download } from "./download";
import { useDocuments } from "./hooks";

export function Documents() {
	const { documents } = useDocuments();
	return (
		<ul className="space-y-2">
			{documents.map((document) => (
				<Item asChild key={document.key}>
					<li>
						<ItemContent>
							<span className="text-gray-200 text-sm line-clamp-1">
								{document.key}
							</span>
						</ItemContent>
						<ItemActions>
							<Download item={document} />
							<Delete item={document} />
						</ItemActions>
					</li>
				</Item>
			))}
		</ul>
	);
}
components/documents/provider.tsx
"use client";

import { createContext, use, useOptimistic } from "react";

export type BucketItem = { key: string; optimistic?: boolean };

type OptimisticAction =
	| { action: "add"; document: BucketItem }
	| { action: "delete"; document: BucketItem };

export const DocumentsContext = createContext<{
	documents: BucketItem[];
	addOptimistic: (action: OptimisticAction) => void;
} | null>(null);

export default function DocumentsProvider({
	children,
	items: initial,
}: React.PropsWithChildren & { items: Promise<BucketItem[]> }) {
	const [documents, addOptimistic] = useOptimistic(
		use(initial),
		(currentState: BucketItem[], action: OptimisticAction) => {
			switch (action.action) {
				case "add":
					return currentState.concat({
						...action.document,
						optimistic: true,
					});
				case "delete":
					return currentState.filter(
						(todo) => todo.key !== action.document.key,
					);
			}
		},
	);

	return (
		<DocumentsContext
			value={{
				documents,
				addOptimistic,
			}}
		>
			{children}
		</DocumentsContext>
	);
}
components/documents/upload.tsx
"use client";

import { Dropzone, DropzoneEmptyState } from "@/components/kibo-ui/dropzone";
import { useDocuments } from "./hooks";
import { startTransition } from "react";
import { uploadToBucket } from "@/lib/bucket/upload";
import documents from "@/workloads/documents";
import { revalidate } from "@/actions/revalidate";

export function Upload() {
	const { addOptimistic } = useDocuments();

	const upload = async (files: File[]) => {
		startTransition(async () => {
			try {
				for (const file of files) {
					addOptimistic({ action: "add", document: { key: file.name } });
					await uploadToBucket({
						bucketName: documents.name,
						file,
						key: file.name,
					});
				}
				await revalidate();
			} catch (e) {
				console.error(e);
			}
		});
	};

	return (
		<div className="py-5">
			<Dropzone onDrop={upload} onError={console.error} className="py-5">
				<DropzoneEmptyState />
			</Dropzone>
		</div>
	);
}

Show documents in home

Finally, connect the documents feature to the “Documents” tab in your app.

app/page.tsx
import { Documents } from "@/components/documents"; 
import DocumentsProvider from "@/components/documents/provider"; 
import { Upload } from "@/components/documents/upload"; 
import { AddTodo } from "@/components/todo-list/add";
import TodosProvider from "@/components/todo-list/provider";
import { Todos } from "@/components/todo-list/todos";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { allDocuments } from "@/lib/documents"; 
import { allTodos } from "@/lib/todos";

export const dynamic = "force-dynamic";

export default function Home() {
	return (
		<TodosProvider todos={allTodos()}>
			<DocumentsProvider items={allDocuments()}>
				<main className="min-h-screen dark text-primary bg-slate-950 p-6">
					<div className="flex flex-col gap-10">
						<h1 className="text-2xl font-bold text-center">
							monolayer Starter
						</h1>
						<Tabs defaultValue="todos" className="items-center w-full">
							<TabsList className="w-2xs">
								<TabsTrigger value="todos">Todos</TabsTrigger>
								<TabsTrigger value="documents">Documents</TabsTrigger>
								<TabsTrigger value="reports">Reports</TabsTrigger>
							</TabsList>
							<TabsContent value="todos" className="w-full align-start">
								<div className="py-4 max-w-2xl mx-auto">
									<AddTodo />
									<Todos />
								</div>
							</TabsContent>
							<TabsContent value="documents" className="w-full align-start">
								<div className="py-4 max-w-2xl mx-auto">
									<Upload />
									<Documents />
								</div>
							</TabsContent>
							<TabsContent value="reports" className="w-full align-start">
								<div className="py-4 max-w-2xl mx-auto">
									Reports placeholder
								</div>
							</TabsContent>
						</Tabs>
					</div>
				</main>
			</DocumentsProvider>
		</TodosProvider>
	);
}

Now switch to the “Documents” tab in your running app — you should be able to upload, download, and delete documents locally.

Empty Documents Documents With Documents


Reports UI

The final tab, Reports, introduces a new workload type: a task — background code that runs independently of the web server.

The Reports tab will have a button that triggers a background task to generate a report, upload it, and make it appear in the Documents tab.

Generate a task workload

Let's start by defining a task workload, monolayer's abstraction for asynchronous background tasks.

npx monolayer add task --name upload-report

Update task code

Now, open workloads/upload-report.ts and replace the contents with:

workloads/upload-report.ts
import { PutObjectCommand } from "@aws-sdk/client-s3"; 
import { Task } from "@monolayer/sdk";
import documents from "./documents"; 
import { publisher } from "@/lib/broadcast/publisher"; 
import { s3Client } from "@/lib/bucket/client"; 

export type UploadReportData = {
	message: string; 
	report: { message: string }; 
};

const uploadReport = new Task<UploadReportData>(
	"upload-report",
	async ({ data }) => {
		console.log("message", data.message); 
		const key = `Report-${new Date().toISOString()}`; 
		const command = new PutObjectCommand({
			Bucket: documents.name, 
			Key: key, 
			Body: Buffer.from(data.report.message), 
		}); 
		await s3Client.send(command); 
	},
);

export default uploadReport;

Add components and actions

actions/generate-report.ts
"use server";

import uploadReport from "@/workloads/upload-report";

export async function generateReport() {
	await uploadReport.performLater({ report: { message: "hello" } });
	return true;
}
components/generate-report.tsx
"use client";

import { generateReport } from "@/actions/generate-report";
import { Button } from "./ui/button";

export function GenerateReport() {
	return (
		<Button
			variant="default"
			className="hover:cursor-pointer"
			onClick={generateReport}
		>
			Generate Report
		</Button>
	);
}

Show Generate Report in home

Finally, connect your report button to the Reports tab in your app's main page.

app/page.tsx
import { Documents } from "@/components/documents";
import DocumentsProvider from "@/components/documents/provider";
import { Upload } from "@/components/documents/upload";
import { GenerateReport } from "@/components/generate-report"; 
import { AddTodo } from "@/components/todo-list/add";
import TodosProvider from "@/components/todo-list/provider";
import { Todos } from "@/components/todo-list/todos";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { allDocuments } from "@/lib/documents";
import { allTodos } from "@/lib/todos";

export const dynamic = "force-dynamic";

export default function Home() {
	return (
		<TodosProvider todos={allTodos()}>
			<DocumentsProvider items={allDocuments()}>
				<main className="min-h-screen dark text-primary bg-slate-950 p-6">
					<div className="flex flex-col gap-10">
						<h1 className="text-2xl font-bold text-center">
							monolayer Starter
						</h1>
						<Tabs defaultValue="todos" className="items-center w-full">
							<TabsList className="w-2xs">
								<TabsTrigger value="todos">Todos</TabsTrigger>
								<TabsTrigger value="documents">Documents</TabsTrigger>
								<TabsTrigger value="reports">Reports</TabsTrigger>
							</TabsList>
							<TabsContent value="todos" className="w-full align-start">
								<div className="py-4 max-w-2xl mx-auto">
									<AddTodo />
									<Todos />
								</div>
							</TabsContent>
							<TabsContent value="documents" className="w-full align-start">
								<div className="py-4 max-w-2xl mx-auto">
									<Upload />
									<Documents />
								</div>
							</TabsContent>
							<TabsContent value="reports" className="w-full align-start">
								<div className="py-4 max-w-2xl mx-auto">
									<GenerateReport />
								</div>
							</TabsContent>
						</Tabs>
					</div>
				</main>
			</DocumentsProvider>
		</TodosProvider>
	);
}

Now, open your app and click Generate Report.

Reports tab

After a few seconds, refresh the Documents tab — you'll see a new file listed, automatically uploaded by the background task.

Reports in Documents


Sending Realtime Updates

Up until now, all the changes you've made in the backend — adding todos, uploading documents, generating reports — have been one-way or limited to the same client. For some features and other clients we need to manually refresh the page to see the updates.

Wouldn't it be nice if new documents just appeared as soon as someone uploaded them? Or if todos synced instantly across open tabs?

In this section, we'll make the app truly live.

Add broadcast workload

A broadcast workload acts as the real-time hub for your app — a WebSocket service that clients can subscribe to for updates.

Generate one with:

npx monolayer add broadcast

Define channels

Channels are logical topics clients can subscribe to and publish events through. You can think of them as rooms in a chat app — each one handles a separate stream of updates.

Change the contents of workloads/broadcast.ts:

workloads/broadcast.ts
import type { Todos } from "@/lib/prisma/generated/client";
import { Broadcast, ChannelData } from "@monolayer/sdk";

export type TodosChannelData =
	| { action: "add"; todo: Todos }
	| { action: "delete"; todoId: string };

export type DocumentsChannelData =
	| { action: "add"; document: { key: string } }
	| { action: "delete"; document: { key: string } };

const broadcast = new Broadcast({
	channels: {
		"/todos": {
			data: new ChannelData<TodosChannelData>(),
		},
		"/documents": {
			data: new ChannelData<DocumentsChannelData>(),
		},
	},
});

export default broadcast;

export type Channels = typeof broadcast._channelDataType;

Publish and subscribe to channels (Documents)

Let's make the Documents tab update live when a new file is uploaded or deleted.

actions/documents.ts
"use server";

import { publisher } from "@/lib/broadcast/publisher"; 
import { s3Client } from "@/lib/bucket/client";
import documents from "@/workloads/documents";
import { DeleteObjectCommand } from "@aws-sdk/client-s3";
import { revalidatePath } from "next/cache";

export async function deleteDocument(key: string) {
	const command = new DeleteObjectCommand({
		Bucket: documents.name,
		Key: key,
	});
	await s3Client.send(command);
	await publisher.publishTo("/documents", {}, [
		{ action: "delete", document: { key } }, 
	]); 
	revalidatePath("/");
}
export async function publishAddDocument(key: string) {
	await publisher.publishTo("/documents", {}, [
		{ action: "add", document: { key } }, 
	]); 
} 
components/documents/provider.tsx
"use client";

import { useChannelSubscription } from "@/lib/broadcast/client"; 
import type { DocumentsChannelData } from "@/workloads/broadcast"; 
import { createContext, use, useOptimistic } from "react";

export type BucketItem = { key: string; optimistic?: boolean };

type OptimisticAction =
	| { action: "add"; document: BucketItem }
	| { action: "delete"; document: BucketItem };

export const DocumentsContext = createContext<{
	documents: BucketItem[];
	addOptimistic: (action: OptimisticAction) => void;
	subscriptionDocuments: DocumentsChannelData[]; 
} | null>(null);

export default function DocumentsProvider({
	children,
	items: initial,
}: React.PropsWithChildren & { items: Promise<BucketItem[]> }) {
	const { all: subscriptionDocuments } = useChannelSubscription(
		"/documents", 
		{}, 
	); 

	const [documents, addOptimistic] = useOptimistic(
		use(initial),
		(currentState: BucketItem[], action: OptimisticAction) => {
			switch (action.action) {
				case "add":
					return currentState.concat({
						...action.document,
						optimistic: true,
					});
				case "delete":
					return currentState.filter(
						(todo) => todo.key !== action.document.key,
					);
			}
		},
	);

	return (
		<DocumentsContext
			value={{
				documents,
				addOptimistic,
				subscriptionDocuments: subscriptionDocuments, 
			}}
		>
			{children}
		</DocumentsContext>
	);
}
components/documents/hooks.tsx
import { use } from "react";
import { DocumentsContext, type BucketItem } from "./provider"; 
import type { DocumentsChannelData } from "@/workloads/broadcast"; 

export function useDocuments() {
	const context = use(DocumentsContext);
	if (!context) {
		throw new Error("useLogViewer must be used within a <DocumentsProvider />");
	}

	return {
		documents: combineDocuments(
			context.documents, 
			context.subscriptionDocuments, 
		), 
		addOptimistic: context.addOptimistic,
	};
}

function combineDocuments(
	items: BucketItem[], 
	subscriptionDocuments: DocumentsChannelData[], 
) {
	const deduplicatedItems = Object.values(
		subscriptionDocuments.reduce<Record<string, DocumentsChannelData>>( 
			(acc, val) => {
				acc[val.document.key] = val; 
				return acc; 
			},
			{}, 
		), 
	); 
	const subscriptionKeys = deduplicatedItems.map((item) => item.document.key); 
	return (
		items 
			.filter((item) => !subscriptionKeys.includes(item.key)) 
			.concat(
				deduplicatedItems 
					.filter((item) => item.action === "add") 
					.map((item) => item.document), 
			) 
	); 
} 
components/documents/upload.tsx
"use client";

import { Dropzone, DropzoneEmptyState } from "@/components/kibo-ui/dropzone";
import { useDocuments } from "./hooks";
import { startTransition } from "react";
import { uploadToBucket } from "@/lib/bucket/upload";
import documents from "@/workloads/documents";
import { revalidate } from "@/actions/revalidate";
import { publishAddDocument } from "@/actions/documents"; 

export function Upload() {
	const { addOptimistic } = useDocuments();

	const upload = async (files: File[]) => {
		startTransition(async () => {
			try {
				for (const file of files) {
					addOptimistic({ action: "add", document: { key: file.name } });
					await uploadToBucket({
						bucketName: documents.name,
						file,
						key: file.name,
					});
					await publishAddDocument(file.name); 
				}
				await revalidate();
			} catch (e) {
				console.error(e);
			}
		});
	};

	return (
		<div className="py-5">
			<Dropzone onDrop={upload} onError={console.error}>
				<DropzoneEmptyState />
			</Dropzone>
		</div>
	);
}

Publish and Subscribe to channels (Todos)

We can do the same for the Todos list.

actions/todos
"use server";

import { publisher } from "@/lib/broadcast/publisher"; 
import prisma from "@/lib/prisma/prisma";
import { revalidatePath } from "next/cache";

export type AddTodoState = {
	input: string;
	error?: string;
};

export async function addTodo(
	_: unknown,
	formData: FormData,
): Promise<AddTodoState> {
	const text = formData.get("todo") as string | undefined;

	if (text?.trim()) {
		const todo = await prisma.todos.create({
			data: { text, id: formData.get("optimisticId")?.toString() ?? undefined },
		});
		revalidatePath("/");
		await publisher.publishTo("/todos", {}, [{ action: "add", todo }]); 
		return { input: "", error: undefined };
	}

	return {
		error: "Todo cannot be empty",
		input: text ?? "",
	};
}

export type DeleteTodoState = {
	error?: string;
	success?: boolean;
};

export async function deleteTodo(formData: FormData): Promise<DeleteTodoState> {
	const todoId = formData.get("todoId") as string | undefined;

	if (todoId) {
		try {
			await prisma.todos.delete({ where: { id: todoId } });
			revalidatePath("/");
			await publisher.publishTo("/todos", {}, [
				{ action: "delete", todoId: todoId }, 
			]); 
			return { success: true };
		} catch {
			return { success: false, error: "Could not delete todo" };
		}
	}
	return { success: false, error: "Could not delete todo" };
}
components/todo-list/provider.tsx
"use client";

import { useChannelSubscription } from "@/lib/broadcast/client"; 
import type { Todos } from "@/lib/prisma/generated/client";
import type { TodosChannelData } from "@/workloads/broadcast"; 
import { createContext, use, useOptimistic } from "react";

type OptimisticAction =
	| { kind: "add"; todo: Todos }
	| { kind: "remove"; todoId: Todos["id"] };

export const TodosContext = createContext<{
	todos: Todos[];
	addOptimistic: (action: OptimisticAction) => void;
	subscriptionTodos: TodosChannelData[]; 
} | null>(null);

export default function TodosProvider({
	children,
	todos: initial,
}: React.PropsWithChildren & { todos: Promise<Todos[]> }) {
	const { all: subscriptionTodos } = useChannelSubscription("/todos", {}); 

	const [todos, addOptimistic] = useOptimistic(
		use(initial),
		(currentState: Todos[], action: OptimisticAction) => {
			switch (action.kind) {
				case "add":
					return currentState.concat(action.todo);
				case "remove":
					return currentState.filter((todo) => todo.id !== action.todoId);
			}
		},
	);

	return (
		<TodosContext
			value={{
				todos,
				addOptimistic,
				subscriptionTodos, 
			}}
		>
			{children}
		</TodosContext>
	);
}
components/todo-list/hooks.tsx
import { TodosContext } from "./provider";
import { toast } from "sonner";
import { addTodo, deleteTodo, type AddTodoState } from "@/actions/todos";
import { use, useActionState } from "react";
import { v4 } from "uuid";
import type { Todos } from "@/lib/prisma/generated/client";
import type { TodosChannelData } from "@/workloads/broadcast"; 

export function useTodos() {
	const context = use(TodosContext);
	if (!context) {
		throw new Error("useLogViewer must be used within a <TodosProvider />");
	}

	return {
		todos: combineTodos(context.todos, context.subscriptionTodos), 
		addOptimistic: context.addOptimistic,
	};
}

export function useAddTodoActionState() {
	const { addOptimistic } = useTodos();

	const add = async (_: unknown, formData: FormData): Promise<AddTodoState> => {
		const todo = formData.get("todo")?.toString();
		const optimisticId = v4();
		if (todo) {
			const now = new Date();
			addOptimistic({
				kind: "add",
				todo: {
					id: optimisticId,
					text: todo,
					createdAt: now,
					updatedAt: now,
				},
			});
		}
		formData.set("optimisticId", optimisticId);
		return addTodo(_, formData);
	};

	return useActionState(add, {
		input: "",
	});
}

export function useDeleteTodoAction(todo: Todos) {
	const { addOptimistic } = useTodos();

	return async (form: FormData) => {
		addOptimistic({ kind: "remove", todoId: todo.id });
		const response = await deleteTodo(form);
		if (response.error) toast.error(response.error);
	};
}

function combineTodos(todos: Todos[], subscriptionItems: TodosChannelData[]) {
	const deduplicatedItems = Object.values(
		subscriptionItems.reduce<Record<string, TodosChannelData>>((acc, val) => {
			const id = val.action === "add" ? val.todo.id : val.todoId; 
			acc[id] = val; 
			return acc; 
		}, {}), 
	); 
	const subscriptionIds = deduplicatedItems.map(
		(item) => (item.action === "add" ? item.todo.id : item.todoId), 
	); 

	return (
		todos
			.filter((todo) => !subscriptionIds.includes(todo.id)) 
			.concat(
				deduplicatedItems 
					.filter((item) => item.action === "add") 
					.map((item) => item.todo), 
			) 
			.sort((a, b) => {
				return (
					new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() 
				); 
			}) 
	); 
} 
workloads/upload-report.ts
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { Task } from "@monolayer/sdk";
import documents from "./documents";
import { publisher } from "@/lib/broadcast/publisher"; 
import { s3Client } from "@/lib/bucket/client";

export type UploadReportData = {
	report: { message: string };
};

const uploadReport = new Task<UploadReportData>(
	"generate-report",
	async ({ data }) => {
		const key = `Report-${new Date().toISOString()}`;
		const command = new PutObjectCommand({
			Bucket: documents.name,
			Key: key,
			Body: Buffer.from(data.report.message),
		});
		await s3Client.send(command);
		await publisher.publishTo("/documents", {}, [
			{ action: "add", document: { key } }, 
		]); 
	},
);

export default uploadReport;
app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/sonner";
import { BroadcastProvider } from "@monolayer/sdk"; 

const geistSans = Geist({
	variable: "--font-geist-sans",
	subsets: ["latin"],
});

const geistMono = Geist_Mono({
	variable: "--font-geist-mono",
	subsets: ["latin"],
});

export const metadata: Metadata = {
	title: "Create Next App",
	description: "Generated by create next app",
};

export default function RootLayout({
	children,
}: Readonly<{
	children: React.ReactNode;
}>) {
	return (
		<html lang="en">
			<body
				className={`${geistSans.variable} ${geistMono.variable} antialiased`}
			>
				<BroadcastProvider>{children}</BroadcastProvider> // [!code ++]
				<Toaster theme="dark" position="top-right" />
			</body>
		</html>
	);
}

Start the development environment for broadcast

Finally, start your local development environment so the broadcast system runs:

npx monolayer start dev

Dev start Broadcast

Open two browser windows side by side. Navigate to your app in both and try uploading a document or adding a todo.

You'll see updates appear instantly in both — no reloads needed.


Lifecycle Hooks

monolayer allows you to define lifecycle hooks for your applications, enabling you to execute custom logic at key stages of your application deployment.

With lifecycle hooks, you can automate tasks during environment bootstrapping and before rolling out new application versions.

We'll deploy the database when setting up a new environment.

Run the following command and select db:deploy:

npx monolayer add bootstrap

We'll also deploy the database before rolling out a new application version.

Run the following command and select db:deploy:

npx monolayer add before-rollout

Prerequisites and Setup

Previous Page

Deploy the Application

Next Page

On this page

Implementing TodosAdding a Todos modelMigrate the databaseAdd Todo list componentsShow todo list in homeBuilding the Documents FeatureGenerate a bucket workloadStart local environment for bucketAdd Components and Server ActionsShow documents in homeReports UIGenerate a task workloadUpdate task codeAdd components and actionsShow Generate Report in homeSending Realtime UpdatesAdd broadcast workloadDefine channelsPublish and subscribe to channels (Documents)Publish and Subscribe to channels (Todos)Start the development environment for broadcastLifecycle Hooks