monolayer Docs
monolayer Docs
Introduction

Getting Started

Overview and setupMain Page UIImplementing TodosBuilding the Documents FeatureReports UISending realtime updatesLifecycle HooksNext Steps
Install monolayer in your AWS accountAdd a git connectorDeploy your app in monolayer

Platform

Other

Feedbackmonolayer SDK Docsmonolayer.devFAQs
Your first app

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 in 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 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

Implementing Todos

Previous Page

Reports UI

Next Page

On this page

Generate a bucket workloadStart local environment for bucketAdd Components and Server ActionsShow documents in home