Back to blog

Using File-Centric Architecture to Build Simple and Capable Apps

Using File-Centric Architecture to Build Simple and Capable Apps

Steve

Building modern web apps often follows a familiar structure, with developers typically having the necessary tools in mind. Many websites today are simple CRUD applications: a front-end client authenticates users and sends requests to a server that updates a database. Files also play a significant role, supporting rich media experiences and more. While this setup might seem straightforward, it can quickly lead to a cascade of additional tools, dependencies, and SaaS subscriptions. As a result, you end up with an app scattered across multiple repositories and services, which introduces extra complexity and increases the potential for errors. However…

File-Centric Architecture

As our team continues to build real world apps using Pinata, we started to notice a trend. We kept having to create databases and other services to manage state, which slowly started to get a bit hairy. Then we realized a better way: just use Pinata. These apps generally revolve around a central file, such as an image, video, audio, or even text file. When you upload a file to Pinata you have the option to attach additional metadata to the file, including key-values. The key-values proves to be truly capable, as it can hold all of that additional state you would normally use a database for. Not only that, it’s a breeze to use. Here is what uploading a file with key-values looks like:

const upload = await pinata.upload.file(file)
	.addMetadata({
		name: "book.epub"
		keyvalues: {
			title: "The Art of Science and Engineering",
			author: "Richard Hemming",
			isbn: "1732265178",
			genre: "engineering",
			publish_year: "2020"
		}
	})

After it’s uploaded, we can query for the file along with all of its info and context:

const file = await pinata.files.list().metadata({ author: "Richard Hemming" })

// Returns the following
{
  "id": "d2fdcf5b-3a98-4621-baa1-9e0991f74480",
  "name": "book.epub",
  "cid": "bafkreiflqfjlvejqgyt7w7i5gxhgr3awkiopezuxhs7k4vl6h4q2tph43i",
  "size": 94900,
  "number_of_files": 1,
  "mime_type": "text/plain; charset=UTF-8",
  "keyvalues": {
    "title": "The Art of Science and Engineering",
    "author": "Richard Hamming",
    "isbn": "1732265178",
    "genre": "engineering",
    "publish_year": "2020"
  },
  "group_id": "1670a136-5cd8-4b61-946a-f25051807a4c",
  "created_at": "2024-11-08T20:17:18.098935Z"
}

With this file-centered approach, you’re able to focus on the primary content and keep the context close by. No need to add extra services, make additional API calls, and just complicate the app further. You can just use Pinata with a simple, yet pleasant, developer experience that has all the speed and scalability of other major providers.

In Practice: Snippets.so

A few months ago, I built Snippets.so to solve one of my own needs: a simple and clean app to share code snippets. I stuck with a pretty basic structure, where the content of the snippet, the language, and the file extension were stored in a JSON file, which was then uploaded to Pinata. While this worked, it was also limiting some of the features I wanted to implement. This lead to a refactor of the app to use both the Pinata Files API as well as the key-values, and what a difference that made. It enabled me to add some more advance features such as setting public or private files, and setting a expiration date for the snippet - all without needing a database. Let’s take a look at what the upload looks like.

"use server";

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { PinataSDK, type UploadResponse } from "pinata";
const argon2 = require("argon2");

const pinata = new PinataSDK({
	pinataJwt: process.env.PINATA_JWT,
});

export async function POST(request: NextRequest) {
	try {
		const body = await request.json();
		const file = new File([body.content], body.name, { type: "text/plain" });
		let passwordHash = "";
		if (body.password) {
			passwordHash = await argon2.hash(body.password);
		}
		const res: UploadResponse = await pinata.upload
			.file(file)
			.addMetadata({
				name: body.name,
				keyvalues: {
					lang: body.lang,
					private: body.isPrivate,
					expires: body.expires,
					passwordHash: passwordHash
				},
			})
			.group(body.isPrivate === "true" ? "" : process.env.GROUP_ID!);
		return NextResponse.json({
			IpfsHash: res.cid,
		});
	} catch (error) {
		console.log(error);
		return NextResponse.json(error);
	}
}

Here, we have a basic Next.js API route that takes all the necessary info from the request body and parses it into several places. At the center, we have the file which holds the main content of the snippet. From there, we use addMetadata to designate a name, as well as the keyvalues to hold crucial info about whether the file should be private and how long it’s accessible. At the end, we also decide if we need to upload the file to a public group or leave it as is to keep it private. The upload results in an object like the one below.

{
  "id": "01930d6c-8b52-72cc-b398-02952c44c7ab",
  "name": "page.tsx",
  "cid": "bafkreiflqfjlvejqgyt7w7i5gxhgr3awkiopezuxhs7k4vl6h4q2tph43i",
  "size": 949,
  "number_of_files": 1,
  "mime_type": "text/plain",
  "keyvalues": {
    "expires": "600",
    "lang": "tsx",
    "private": "true",
    "passwordHash": "$argon2id$v=19$m=65536,t=3,p=4$REDACTED$",
  },
  "group_id": "01930498-0a89-7376-a355-e7b94639ab24",
  "created_at": "2024-11-08T20:17:18.098935Z"
}

The cid is a cryptographic hash of the file that prevents duplication in the Pinata account, but it also acts as the address to access the file. With it, we can create a dynamic route in the app like snippets.so/snip/:cid which will take the path argument and turn it into usage state.

import { Header } from "@/components/header";
import { ReadOnlyEditor } from "@/components/read-only-editor";
import { PinataSDK } from "pinata";
import type { LanguageName } from "@uiw/codemirror-extensions-langs";

interface SnippetData {
	content: string;
	name: string;
	lang: LanguageName;
	expires: string;
	date: string;
	passwordHash: string;
}

const pinata = new PinataSDK({
	pinataJwt: process.env.PINATA_JWT,
	pinataGateway: process.env.GATEWAY_DOMAIN,
});

async function fetchData(cid: string): Promise<SnippetData | Error> {
	try {
		const fileInfo = await pinata.files.list().cid(cid);
		const file = fileInfo.files[0];
		const { data: content } = await pinata.gateways.get(cid);
		const res: SnippetData = {
			content: content,
			name: file.name,
			lang: file.keyvalues.lang,
			expires: file.keyvalues.expires,
			date: file.created_at,
			passwordHash: file.keyvalues.passwordHash
		};
		return res;
	} catch (error) {
		console.log(error);
		return error as Error;
	}
}

// Rest of the code to render the UI

Here is the dynamic server rendered page that make two requests with the provided cid:

  • One is a request is to get the previously mentioned file object along with all the key-values
  • The second is to actually fetch the content of the snippet

With both of these requests we can take the results and merge them into a single object with all the information we need to render it on the screen, as well as perform some really cool features. To handle the expiration, we simply take the date the file was uploaded, add on the number of seconds in the expires field of the key-values, then do a check to see if the current date is greater than the expiration date.

export default async function Page({ params }: { params: { cid: string } }) {
	const cid = params.cid;
	const data = await fetchData(cid);

	let hasExpired = false;
	let futureDate: Date | undefined;
	
	if (data.expires !== "0") {
		const date = new Date(data.date);
		futureDate = new Date(
			date.getTime() + Number.parseInt(data.expires) * 1000,
		);
		const currentDate = new Date();
		hasExpired = currentDate > futureDate;
	}
	
	// Rest of the UI render
}

Another feature key-values enabled on Snippets is password protection on shared snips. During the upload flow, we take the password from the request body and create an Argon2 password hash and store it in the key-values along with everything else. Then, when it’s time to render the content, we can check if a password is present, then prompt the user to enter the password for the snip. When provided, we have an API route that will use Argon2 to verify the password and then pass the private content to the client.

import { NextResponse } from "next/server";
import { PinataSDK } from "pinata";
import * as argon2 from "argon2";

const pinata = new PinataSDK({
	pinataJwt: process.env.PINATA_JWT,
	pinataGateway: process.env.GATEWAY_DOMAIN,
});

export async function POST(
	request: Request,
	{ params }: { params: { cid: string } },
) {
	try {
		const body = await request.json();
		const { data: content } = await pinata.gateways.get(cid)

		const fileInfo = await pinata.files.list().cid(params.cid);
		const file = fileInfo.files[0];

		const password = String(body.password);
		const hash = String(file.keyvalues.passwordHash);

		try {
			const isValid = await argon2.verify(hash, password);
			if (isValid) {
				return NextResponse.json({ content });
			}
		} catch (verifyError) {
			console.error("Verification error:", verifyError);
		}

		return NextResponse.json({ error: "Invalid password" }, { status: 401 });
	} catch (error) {
		return NextResponse.json({ error }, { status: 500 });
	}
}

The beauty of this flow is that Pinata keeps everything private by default: the file we uploaded and the key-values that have the password hash are all safe and secure. In the end, we have a simple auth layer without an auth provider. If I did want to use full auth on this app, I could easily use groups in the Files API to assign files to a specific user. If that sounds interesting, then check out this post!

Wrapping Up

Needless to say, we’re pretty excited at the possibilities unlocked with this frame of thinking for building apps. Files are a crucial and fundamental building block for computing, and it makes sense to keep the data surrounding them nearby. Not only that, but building apps around files should be simple and easy for developers to do, and that’s exactly why Pinata exists.

Experience it for yourself today by signing up for a free account, and let your mind explore the possibilities!

Subscribe to paid plan image

Share this post:

Stay up to date

Join our newsletter for the latest stories & product updates from the Pinata community.