Back to blog
Building Snippets.so
"I don't know why this isn't working" is a question I get every now and then, and I usually respond with "could you share your code with me?" Next thing you know I get a cell phone image of someone's fingerprint-covered laptop screen with a blur of code on it. I wish this wasn't the first time, and I certainly hope it's the last, but to be honest there was a problem.
I continue to find myself needing to share a code snippet with a user, or needing them to share one with me. Of course there is Pastebin, perhaps the most popular, but it's littered with ads and bloat that I hated to use or point people to. There's also Gists, which generally are great, but only if you want them to stick to your Github profile (which I usually don't). On the other side of the spectrum there's Ray.so, a beautiful app built by the team at Raycast that generates the best looking images of code you can get. Unfortunately that doesn't always help me if I need to copy and paste the code somewhere.
This is what led me to build Snippets.so, my take on a cleaner and more efficient way to share code. I wanted the style and ease of Ray.so with the simplicity of Pastebin, and I'm happy with the ground I found in the middle. I decided to take a few moments to share how it was built and some of the unique stack choices I made in hopes that you may find it beneficial, or perhaps use Snippets.so for your own code sharing needs.
The Stack
For this app I took a few obvious choices for the stack, but others that might puzzle the majority of developers out there. Let's start right off the bat with the weird one ;)
IPFS
If you're not familiar the InterPlanetary File System (IPFS), it’s a unique distributed file sharing protocol commonly used alongside blockchains. In the app I use it to store JSON files that contain everything I need for each snippet, and it's convenient that the CID (Content Identifier) or address to the file works as a unique identifier as well which we'll cover soon. However there are some other benefits I wanted to outline as well.
One of the main reasons blockchain developers use IPFS for their offchain storage is due to immutability. Once something is on the network it cannot change, and in our case that's very useful. People generally don't need to update a snippet once they share it, and it makes the content ideal for a CDN for high cache hit rates. Once a snippet is loaded at least once, the other requests will be very speedy.
Since CIDs are cryptographically determined by the content of the file, uploading the same content will give you the same CID. This prevents any possibility of duplicate storage and taking up unwanted space. Additionally CIDs work both as a content hash and the address to the content. It's a publicly accessible network where anyone can take a CID and access the content through a gateway, which adds a nice layer of interoperability.
Next.js
I've certainly had bad history with Next, particularly with heavy caching in API routes. However it does provide a pretty nice experience when doing server side rendering, which is the majority of Snippets. I've also built so many projects on Next that its almost second nature to me, and I appreciate the speed that brings. If I had to pick another framework I might try Astro instead. For this app Next worked just fine.
shadcn/ui
Undoubtedly the best component library out there, I love every chance I get to use it. Anything I really need is right there in the docs, and its super easy to customize if I need to. Would highly recommend giving it a shot if you haven't already!
Building the Editor
When it comes to enabling writing in an app beyond just a text area there are many library choices out there. For Snippets I went with @uiw/codemirror
for several reasons. For starters it was pretty easy to use and setup, had it running in no time.
import React from 'react';
import CodeMirror from '@uiw/react-codemirror';
import { javascript } from '@codemirror/lang-javascript';
function App() {
const [value, setValue] = React.useState("console.log('hello world!');");
const onChange = React.useCallback((val, viewUpdate) => {
console.log('val:', val);
setValue(val);
}, []);
return <CodeMirror value={value} height="200px" extensions={[javascript({ jsx: true })]} onChange={onChange} />;
}
export default App;
It also has a huge number of supported languages for syntax highlighting. For this app I chose some popular ones and used an extension to load based on a state change.
// other imports
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
import { languages } from "@/lib/languages";
export function CodeForm({ readOnly, content }: any) {
//...
const [value, setValue] = useState(defaultCode);
const [lang, setLang]: any = useState("tsx");
const languageExtension = useMemo(() => {
const extension = loadLanguage(lang);
return extension ? [extension] : [];
}, [lang]);
//...
return (
{/*rest of UI*/}
<CodeMirror
className="text-md opacity-75 p-2 sm:w-[600px] sm:h-[700px] w-[350px] h-[450px]"
height="100%"
width="100%"
value={value}
basicSetup={{
lineNumbers: false,
foldGutter: false,
}}
extensions={languageExtension}
onChange={onChange}
theme={githubLight}
readOnly={readOnly}
/>
{/*...*/}
);
}
While this library does have several themes to choose from I decided to stick with a light theme (blasphemy I know haha). A simple GitHub light theme with reduced opacity actually does a decent job. Definitely looked into trying to customize it a bit more but the way it handles syntax highlighting isn't as good as something like shiki. This might be something I look into down the road.
When it comes to actually uploading the content after the user has put their code in I used the built in API routes in Next.js, simply passing in the content
of the file, name
if one given, and lang
for the language used from the dropdown menu. As mentioned earlier I'm using Pinata/IPFS for uploading the content and there is a convenient API route for JSON objects, so its a simple plug and post operation.
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export const revalidate = 0;
type PinResponse = {
IpfsHash: string;
PinSize: number;
Timestamp: string;
isDuplicate?: boolean;
};
export async function POST(request: NextRequest) {
try {
const body = await request.json();
console.log(body);
const data = JSON.stringify({
pinataContent: {
content: body.content,
name: body.name,
lang: body.lang,
},
pinataMetadata: {
name: body.name,
},
pinataOptions: {
cidVersion: 1,
},
});
const req = await fetch("https://api.pinata.cloud/pinning/pinJSONToIPFS", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.PINATA_JWT}`,
},
body: data,
});
const res: PinResponse = await req.json();
return NextResponse.json(res);
} catch (error) {
console.log(error);
return NextResponse.json(error);
}
}
The API request returns an IpfsHash
or CID that acts as both a unique identifier and the address to the content on the network. Inside the submissionHandler
function used for uploading I'm able to use router.push
to move the user from the editing page to the dynamic route where you view the snippet.
async function submitHandler() {
try {
setLoading(true);
const body = JSON.stringify({
content: value,
name: name,
lang: lang,
});
const req = await fetch(`/api/upload`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: body,
});
const res = await req.json();
setComplete(true);
router.push(`/snip/${res.IpfsHash}`);
} catch (error) {
console.log(error);
setLoading(false);
return error;
}
}
Building the Renderer
Now that the user has uploaded their snippet to IPFS and we have a CID representing the JSON data, we can use it as a path variable and extract the data from it. In our app we use the following file structure:
app
├── api
│ ├── languages
│ │ └── route.ts
│ └── upload
│ └── route.ts
├── favicon.ico
├── globals.css
├── layout.tsx
├── page.tsx
└── snip
└── [cid]
└── page.tsx
[cid]
is our dynamic path variable, and with server side pages we can pull the data from IPFS and feed it into our renderer component in one fell swoop.
import { Footer } from "@/components/footer";
import { Header } from "@/components/header";
import { ReadOnlyEditor } from "@/components/read-only-editor";
async function fetchData(cid: string) {
try {
const req = await fetch(
`https://${process.env.GATEWAY_DOMAIN}/ipfs/${cid}`,
);
const res = await req.json();
return res;
} catch (error) {
console.log(error);
return error;
}
}
export default async function Page({ params }: { params: { cid: string } }) {
const cid = params.cid;
const data = await fetchData(cid);
return (
<main className="flex min-h-screen flex-col items-center sm:justify-center justify-start">
<Header />
<ReadOnlyEditor
content={data.content}
name={data.name}
cid={cid}
lang={data.lang}
/>
<Footer />
</main>
);
}
This really is one of my favorite parts with Next.js App router; if you can structure the project correctly where you feed server data into client components you get the best of both worlds.
With our cid
, name
, content
, and lang
we can rebuild what the editor saw with a "read-only" version of the same editor.
<CodeMirror
className="text-md opacity-75 p-2 sm:w-[600px] sm:h-[700px] w-[350px] h-[450px] font-commitMono"
height="100%"
width="100%"
value={content}
basicSetup={{
lineNumbers: false,
foldGutter: false,
rectangularSelection: false,
}}
extensions={languageExtension}
theme={githubLight}
readOnly
editable={false}
/>
Along with viewing the content we can also enable some fun stuff like copying it to clipboard, downloading it as a file, or sharing the snippet with a link!
API + CLI
Since there isn't really any authentication in this app and anyone can upload snippets as much as they want, I figured "why not make the API accessible?" Anyone can make an API request to the app to make a snippet and use the data returned to make the link!
Request:
curl --location 'https://www.snippets.so/api/upload' \
--header 'Content-Type: application/json' \
--data '{
"content": "console.log(\"hello world!\")",
"name": "hello.ts",
"lang": "typescript"
}'
Returns:
{
"IpfsHash": "bafkreiccdt64k6d4wjgz5ebqee4rvmkauoiygc5egwtssl2zqq3o74zlti",
"PinSize": 81,
"Timestamp": "2024-07-10T02:25:51.052Z",
"isDuplicate": true
}
Link:
https://snippets.so/snip/bafkreiccdt64k6d4wjgz5ebqee4rvmkauoiygc5egwtssl2zqq3o74zlti
Of course this led me to make a CLI in Go for the app as well, which you can download with brew install stevedylandev/snippets-cli/snippets-cli
or by building it yourself from this repo. To use it you can just run the command snip
followed by the file you want to upload.
snip helloWorld.ts
Wrapping Up
Even though this was a relatively simply project I love how it turned out. I started with the goal of making a better tool that I would use and I did just that. A smile creeps onto my face every time I have the chance to use it when helping another developer, and its from the satisfaction of programming away a problem. I truly believe that the most meaningful pieces of code we write are the ones that make our lives just a little bit better, and I can't wait to keep doing just that.