Your first app
Welcome! In this guide, you'll build and your full-stack web application using monolayer.
You'll learn how monolayer simplifies typical full-stack setup and will concentrate on writing app logic.
This guide uses the following stack:
- Next.js (App Router)
- Prisma ORM
- shadcn/ui
- monolayer SDK
We'll be building a small that has a todo list backed by a PostgresSQL database, a document storage, and running background task.
Prerequites
- Node.js.
- Docker running in your local environment.
Setup
Create a Next.js app
Scaffold a new Next.js project.
npx create-next-app@latest monolayer-starter
cd monolayer-starterAdd UI primitives
npx shadcn@latest init --yes --base-color slate
npx shadcn@latest add tabs field input button item sonner
npx shadcn@latest add @kibo-ui/dropzoneInstall monolayer SDK and dependencies
npm install @monolayer/sdk
npm install uuidAdd a Postgres database
npx monolayer add postgres-database --name main-db --orm prismaThis will install prisma ORM, scaffold prima schema directory, create a postgres workload and connect it to the client, and database relates scripts in package.json
Start development environment
npx monolayer start dev
npm run devRight now you'll have a base Next.js app.

Main Page UI
We'll replace the default Next.js landing page with a tabbed interface using TailwindCSS and shadcn/ui components.
Add Toaster to Layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/sonner";
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`}
>
<Toaster theme="dark" position="top-right" />
</body>
</html>
);
}Replace the contents of app/page.tsx
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export default function Home() {
return (
<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>
</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>
);
}Here are some screenshots on how the application should look like:



Implementing Todos
Adding a Todos model
We'll define the Prisma schema for Todos in 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-todosAdd 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.
"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" };
}"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>
</>
);
}"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>
);
}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);
};
}"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>
);
}"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>
);
}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.
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 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.


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 documentsStart local environment for bucket
Run the following command to spin up a compatible S3 service in your local machine.
npx monolayer start devYou should see this:

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.
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);"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("/");
}"use server";
import { revalidatePath } from "next/cache";
export async function revalidate() {
revalidatePath("/");
}"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>
);
}"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>
);
}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,
};
}"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>
);
}"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>
);
}"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.
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 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.

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-reportUpdate task code
Now, open workloads/generate-report.task.ts and replace the contents with:
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
"use server";
import uploadReport from "@/workloads/upload-report";
export async function generateReport() {
await uploadReport.performLater({ report: { message: "hello" } });
return true;
}"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.
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 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.

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

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 realtime hub for your app — a WebSocket service that clients can subscribe for updates.
Generate one with:
npx monolayer add broadcastDefine 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 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.
"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 } },
]);
} "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>
);
}import { use } from "react";
import { DocumentsContext } from "./provider";
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: context.documents,
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),
)
);
} "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.
"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" };
}"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>
);
}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: context.todos,
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()
);
})
);
} 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;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
Open two browser windows side by side. Navigate into 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 bootstrapWe'll also deploy the database before rolling out a new application version.
Run the following command and select "db:deploy":
npx monolayer add before-rolloutNext Steps
- Install monolayer in your AWS account and deploy this full-stack application with a simple git push.
- Explore the monolayer SDK.
- Experiment with channels and subscriptions to build real-time features.