<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Piotr Kulpinski</title>
    <description>The latest articles on DEV Community by Piotr Kulpinski (@piotrkulpinski).</description>
    <link>https://dev.to/piotrkulpinski</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1221643%2F2d45d8ac-488b-4883-988d-b6166b561fb6.png</url>
      <title>DEV Community: Piotr Kulpinski</title>
      <link>https://dev.to/piotrkulpinski</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/piotrkulpinski"/>
    <language>en</language>
    <item>
      <title>How I Built a Custom MCP Server for a Next.js App (Real Example)</title>
      <dc:creator>Piotr Kulpinski</dc:creator>
      <pubDate>Fri, 30 Oct 2026 10:04:16 +0000</pubDate>
      <link>https://dev.to/piotrkulpinski/how-i-built-a-custom-mcp-server-for-a-nextjs-app-real-example-3i29</link>
      <guid>https://dev.to/piotrkulpinski/how-i-built-a-custom-mcp-server-for-a-nextjs-app-real-example-3i29</guid>
      <description>&lt;p&gt;A &lt;strong&gt;custom MCP server&lt;/strong&gt; is a server you build for your own app, so AI agents can use your product the way you do. I built one for Dirstarter, the Next.js directory boilerplate I sell (so yes, I'm biased about the example). An agent connected to it can take a URL, create a listing, fill in its copy and favicon from the website, pick categories and tags, and schedule it for the next free publish day.&lt;/p&gt;

&lt;p&gt;This post is the real MCP server example I wish I'd found when I started. Most results for "MCP server example" are hello-world templates with an &lt;code&gt;echo&lt;/code&gt; tool. Below is a production setup: two servers in one Next.js app, the code that turns existing API procedures into tools, the safety rules, and a real agent session with the numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a custom MCP server is (and when you need one)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;MCP (Model Context Protocol)&lt;/a&gt; is the open standard AI clients use to call tools on other services. Claude, ChatGPT, Cursor and Claude Code all speak it. A server lists its tools, each with a name, a description and an input schema. The agent reads that list and decides which tools to call.&lt;/p&gt;

&lt;p&gt;You need a custom one when the useful actions live in your own product. Ready-made servers already cover GitHub, Notion and Stripe. Nobody else can expose "schedule this listing for the next free publish day" in your app.&lt;/p&gt;

&lt;p&gt;For a &lt;a href="https://dirstarter.com/blog/what-is-a-directory-website" rel="noopener noreferrer"&gt;directory website&lt;/a&gt;, the use case is obvious. The real cost of running a directory is curation: finding tools, writing descriptions, sorting categories. &lt;a href="https://dirstarter.com/blog/openalternative-case-study" rel="noopener noreferrer"&gt;OpenAlternative&lt;/a&gt; takes me 2-3 hours a week to maintain, and adding listings is the repetitive part of that. An agent with the right tools adds one in under a minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  The example: two servers in one Next.js app
&lt;/h2&gt;

&lt;p&gt;Dirstarter runs two MCP servers, because there are two different audiences:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Server&lt;/th&gt;
&lt;th&gt;URL&lt;/th&gt;
&lt;th&gt;Who calls it&lt;/th&gt;
&lt;th&gt;Sign-in&lt;/th&gt;
&lt;th&gt;What it can do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Public&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/mcp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;AI assistants answering their users&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Search and read published listings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Admin&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/api/mcp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your own agent (Claude Code, Cursor)&lt;/td&gt;
&lt;td&gt;Bearer secret&lt;/td&gt;
&lt;td&gt;Add, edit, enrich and schedule content&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The public server lets assistants recommend listings when someone asks for software. The admin server is where agents do the work. Splitting them keeps every write action behind a secret, and lets the public side stay small and read-only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning existing procedures into MCP tools
&lt;/h2&gt;

&lt;p&gt;This is the decision that saved the most time. I didn't write a second API for agents. Dirstarter's server code is a set of typed &lt;a href="https://orpc.dev" rel="noopener noreferrer"&gt;oRPC&lt;/a&gt; procedures, the same ones the admin UI calls. Any procedure becomes an MCP tool by adding one &lt;code&gt;meta.mcp&lt;/code&gt; entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;adminWrites&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;schedule_tool&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Schedule a tool for publication. Without `publishedAt`, it takes the next free slot: the first publish day after today that no other scheduled tool holds.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;readOnlyHint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;destructiveHint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;openWorldHint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;notifyOwner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;toolScheduleSchema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// the same handler the admin UI calls&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At startup, a small registry walks the router and collects every procedure with &lt;code&gt;meta.mcp&lt;/code&gt;. It converts each Zod input schema to the JSON Schema an MCP client expects, with &lt;code&gt;z.toJSONSchema()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The procedure's permission decides which server lists the tool. If a guest is allowed to call it, it goes on the public server. Everything else goes on the admin server. A procedure with no permission defaults to admin, so nothing leaks to the public side by accident.&lt;/p&gt;

&lt;p&gt;When an agent calls a tool, the server runs the procedure itself through the normal middleware. Permissions, rate limits, emails and webhooks behave exactly as they do when I click the button in the admin. There's nothing to keep in sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  Serving MCP from a Next.js route handler
&lt;/h2&gt;

&lt;p&gt;Both servers are stateless. Each request builds a fresh server with the official TypeScript SDK (&lt;a href="https://www.npmjs.com/package/@modelcontextprotocol/server" rel="noopener noreferrer"&gt;&lt;code&gt;@modelcontextprotocol/server&lt;/code&gt;&lt;/a&gt;), handles the call and returns. No sessions, no long-lived connections, so it runs fine on serverless hosting.&lt;/p&gt;

&lt;p&gt;The public route only adds rate limits before handing the request to the SDK:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createStatelessMcpHandler&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;createMcpServer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;public&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;tryCatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;consumePublicRateLimits&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;ORPCError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;refuseMcpRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The limits are 1,000 requests per hour per address and 20,000 in total. The per-address limit is high on purpose: Claude and ChatGPT call from shared address ranges, so a low limit would block real users.&lt;/p&gt;

&lt;p&gt;The admin route checks a bearer token against &lt;code&gt;MCP_SECRET&lt;/code&gt;. It hashes both values first and compares them with &lt;code&gt;timingSafeEqual&lt;/code&gt;, so the check takes the same time whatever the token is. Every call then runs as the site's first admin account. That account also becomes the author of any blog post the agent drafts.&lt;/p&gt;

&lt;p&gt;Connecting an agent is one command, from the &lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;Claude Code MCP docs&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add &lt;span class="nt"&gt;--transport&lt;/span&gt; http my-directory-admin https://your-site.com/api/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;MCP_SECRET&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Writing tools an agent can actually use
&lt;/h2&gt;

&lt;p&gt;The plumbing is the easy part. Getting agents to use the tools well is harder, and four things made the difference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Descriptions are prompts.&lt;/strong&gt; Each description says what the tool does, when to call it and what it returns. The server also sends instructions with the recommended order for adding a tool: check for duplicates, create a draft, enrich it, review it, schedule it. In my test sessions, agents followed that order every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Errors say what to fix.&lt;/strong&gt; A failed call returns its code, such as &lt;code&gt;BAD_REQUEST&lt;/code&gt;, followed by each invalid field and what's wrong with it. Agents fix their input and retry instead of giving up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Defaults differ from the UI.&lt;/strong&gt; In the admin, changing a tool's status emails its owner by default. For an agent doing bulk edits, that would spam every owner it touches. So the MCP tools default &lt;code&gt;notifyOwner&lt;/code&gt; to false, and the agent has to ask for an email explicitly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tool list stays small.&lt;/strong&gt; A test checks that each server stays under 25 tools and 25 KB of definitions. Some clients load every tool into context, and a bloated list makes agents worse at picking the right one. List tools also drop long Markdown bodies from their results and leave them to the matching &lt;code&gt;get_&lt;/code&gt; tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  The safety rules
&lt;/h2&gt;

&lt;p&gt;An agent with write access to a live site needs guardrails in the tools, not only in the prompt.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nothing deletes.&lt;/strong&gt; There's no delete tool on either server. To take something down, the agent sets it to Draft. Deleting stays a manual action in the admin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User text is data.&lt;/strong&gt; Reports, submitter notes and listing descriptions are written by strangers, and any of them could contain a prompt injection. The server instructions tell the agent to treat that text as data and never act on requests inside it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New records start as drafts.&lt;/strong&gt; Nothing an agent creates goes live until it's scheduled, and the daily publish job handles the rest.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A real agent session
&lt;/h2&gt;

&lt;p&gt;To check the whole flow end to end, I started a fresh Dirstarter instance locally, connected Claude Code to the admin server and gave it one sentence:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzfb5mc09i5y6fex6it2y.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzfb5mc09i5y6fex6it2y.webp" alt="A Claude Code session adding Cal.com to a directory through the admin MCP server" width="800" height="611"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The agent made 7 tool calls and finished in &lt;strong&gt;54 seconds&lt;/strong&gt;. It checked for a duplicate, read the 8 categories and 21 tags, created the draft, and called &lt;code&gt;enrich_tool&lt;/code&gt;. That tool scraped cal.com and filled the tagline, description, page content and favicon. Then it scheduled the listing for the next free day.&lt;/p&gt;

&lt;p&gt;It also reported a gap honestly: no screenshot. My local copy had no S3 bucket configured, and Dirstarter skips screenshots without storage. On a real deployment that step fills in too.&lt;/p&gt;

&lt;p&gt;Here's the result, a scheduled listing with its preview page:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4f44lbaez8rzhn9x662z.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4f44lbaez8rzhn9x662z.webp" alt="The Cal.com listing the agent created, shown as a scheduled preview" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My earlier runs were less clean, and they taught me something. When the scraper failed, the agent wrote the copy from its own knowledge and flagged that it hadn't checked the claims. That's the right behavior. It also shows why the tools should leave a draft behind for you to review.&lt;/p&gt;

&lt;h2&gt;
  
  
  The public server: getting recommended by AI assistants
&lt;/h2&gt;

&lt;p&gt;The second server is about distribution, a new channel next to search and &lt;a href="https://dirstarter.com/blog/programmatic-seo-directory-websites" rel="noopener noreferrer"&gt;programmatic SEO&lt;/a&gt;. When someone asks Claude or ChatGPT for a scheduling tool, an assistant connected to your directory can search it and link to your listings.&lt;/p&gt;

&lt;p&gt;Three tools cover it: &lt;code&gt;search_tools&lt;/code&gt;, &lt;code&gt;get_tool&lt;/code&gt; and &lt;code&gt;list_categories&lt;/code&gt;. All of them are read-only, and results are ordered by relevance alone. Paid tiers play no part, because the Claude and ChatGPT directories don't allow paid placement.&lt;/p&gt;

&lt;p&gt;Every outbound link goes through a &lt;code&gt;/go/&amp;lt;slug&amp;gt;&lt;/code&gt; redirect that records the click as an &lt;code&gt;mcp&lt;/code&gt; visit in analytics. Assistants don't report the traffic they send, so without the redirect you'd never see it.&lt;/p&gt;

&lt;p&gt;I'll be upfront: I don't have traffic numbers from this yet. The server only becomes useful at scale once it's listed in the assistant directories, and those reviews take time. I'll write about the results when I have real data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you build a custom MCP server?
&lt;/h2&gt;

&lt;p&gt;If your app has actions people repeat every week, yes. It works with any stack that has typed server code, and most of the &lt;a href="https://dirstarter.com/blog/best-nextjs-templates" rel="noopener noreferrer"&gt;best Next.js templates&lt;/a&gt; have that already. The work is smaller than it looks when you already have typed procedures, because the MCP layer is mostly a registry and two route handlers. The hard part is tool design: clear descriptions, safe defaults and no destructive tools.&lt;/p&gt;

&lt;p&gt;If you're building a directory, you don't have to build it from scratch. Dirstarter ships both servers, and the &lt;a href="https://dirstarter.com/docs/mcp" rel="noopener noreferrer"&gt;MCP setup guide&lt;/a&gt; covers connecting an agent in a few minutes. If you're still at the planning stage, start with &lt;a href="https://dirstarter.com/blog/how-to-build-a-directory-website" rel="noopener noreferrer"&gt;how to build a directory website&lt;/a&gt;, then come back to this once you have listings to manage.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://dirstarter.com/blog/custom-mcp-server" rel="noopener noreferrer"&gt;Dirstarter blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>nextjs</category>
      <category>ai</category>
      <category>typescript</category>
    </item>
    <item>
      <title>7 Open-Source Tools for Better Website Analytics</title>
      <dc:creator>Piotr Kulpinski</dc:creator>
      <pubDate>Tue, 14 Jan 2025 14:22:33 +0000</pubDate>
      <link>https://dev.to/piotrkulpinski/7-open-source-tools-for-better-website-analytics-1lhl</link>
      <guid>https://dev.to/piotrkulpinski/7-open-source-tools-for-better-website-analytics-1lhl</guid>
      <description>&lt;p&gt;If you are running a website, SaaS, or online business, having a good analytics tool is important for understanding your website's performance and growth. Analytics tools can give you valuable information about user behavior, where your traffic comes from, and how people engage with your site.&lt;/p&gt;

&lt;p&gt;In 2025, open-source analytics tools have become more powerful and affordable, giving businesses of all sizes a way to track and analyze their data without breaking the bank. &lt;/p&gt;

&lt;p&gt;Let's dive into the list:&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Umami
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd7t7c542fd7954o13x5f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd7t7c542fd7954o13x5f.png" alt="Umami Website Analytics" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're looking for a modern and easy-to-use open-source analytics tool that helps you analyze and manage your website traffic, Umami is the perfect one. Umami shows you who visits your site, where they come from, and what they do. It offers tools to track custom events, spot trends, and analyze user behavior, making it great for growing your website.&lt;/p&gt;

&lt;p&gt;You can get started in just minutes with no complicated setup. It's also privacy-friendly and works well for businesses of any size. You can use it for free until you reach a limit of 100,000 events per month.&lt;/p&gt;

&lt;p&gt;If you want clear, real-time insights without the clutter, Umami is the solution you need. It's the best alternative for Fathom Analytics, Kissmetrics, and Google Analytics.&lt;/p&gt;

&lt;p&gt;It's the best alternative for &lt;a href="https://openalternative.co/alternatives/fathom-analytics" rel="noopener noreferrer"&gt;Fathom Analytics&lt;/a&gt;, &lt;a href="https://openalternative.co/alternatives/google-analytics" rel="noopener noreferrer"&gt;Google Analytics&lt;/a&gt;, &lt;a href="https://openalternative.co/alternatives/kissmetrics" rel="noopener noreferrer"&gt;Kissmetrics&lt;/a&gt;, and &lt;a href="https://openalternative.co/alternatives/mixpanel" rel="noopener noreferrer"&gt;MixPanel&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Project Link: &lt;a href="https://openalternative.co/umami" rel="noopener noreferrer"&gt;&lt;strong&gt;Umami&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  2. OpenPanel
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvxibnj574fp0snn32cfa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvxibnj574fp0snn32cfa.png" alt="OpenPanel Website Analytics" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;OpenPanel is an easy-to-use, open-source analytics tool that helps you monitor and understand how people interact with your website or app. It offers a strong alternative to Mixpanel &amp;amp; Plausible without the high costs.&lt;/p&gt;

&lt;p&gt;With real-time analytics and clear dashboards, you can quickly see what works and what doesn't. It includes features like user tracking, event analysis, and detailed reports to help you make better decisions. This tool respects your privacy while providing key information in an easy-to-use format. Easily check visitor details, referrals, popular pages, entry and exit points, device usage, sessions, bounce rate, duration, and geographical data. Use these insights to make informed decisions effortlessly.&lt;/p&gt;

&lt;p&gt;It's perfect for developers and businesses that want control and flexibility.&lt;/p&gt;

&lt;p&gt;It's the best alternative for &lt;a href="https://openalternative.co/alternatives/fathom-analytics" rel="noopener noreferrer"&gt;Fathom Analytics&lt;/a&gt;, &lt;a href="https://openalternative.co/alternatives/segment" rel="noopener noreferrer"&gt;Segment&lt;/a&gt;, &lt;a href="https://openalternative.co/alternatives/google-analytics" rel="noopener noreferrer"&gt;Google Analytics&lt;/a&gt;, &lt;a href="https://openalternative.co/alternatives/amplitude" rel="noopener noreferrer"&gt;Amplitude&lt;/a&gt;, &lt;a href="https://openalternative.co/alternatives/kissmetrics" rel="noopener noreferrer"&gt;Kissmetrics&lt;/a&gt;, and &lt;a href="https://openalternative.co/alternatives/mixpanel" rel="noopener noreferrer"&gt;MixPanel&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Project Link: &lt;a href="https://openalternative.co/openpanel" rel="noopener noreferrer"&gt;&lt;strong&gt;OpenPanel&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Countly
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmi3ig55w5m370xu189ql.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmi3ig55w5m370xu189ql.png" alt="Countly Website Analytics" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This tool is a little bit different from others, as it provides insights based on how your users interact with your products. It's an All-in-One analytic tool for mobile, website, and desktop apps.&lt;/p&gt;

&lt;p&gt;Countly helps you understand how people use your apps and websites. It gathers data from all your platforms and shows you what works well and what doesn't. With Countly, you can make smart decisions to improve your business without needing many different tools. It protects your privacy, so your data stays safe.&lt;/p&gt;

&lt;p&gt;You can customize Countly to fit your needs by creating special dashboards or adding extra features. Countly makes analytics simple and powerful for businesses of all sizes.&lt;/p&gt;

&lt;p&gt;It's the best alternative for &lt;a href="https://openalternative.co/alternatives/june" rel="noopener noreferrer"&gt;June&lt;/a&gt;,  &lt;a href="https://openalternative.co/alternatives/fathom-analytics" rel="noopener noreferrer"&gt;Fathom Analytics&lt;/a&gt;, &lt;a href="https://openalternative.co/alternatives/google-analytics" rel="noopener noreferrer"&gt;Google Analytics&lt;/a&gt;, &lt;a href="https://openalternative.co/alternatives/amplitude" rel="noopener noreferrer"&gt;Amplitude&lt;/a&gt;, &lt;a href="https://openalternative.co/alternatives/kissmetrics" rel="noopener noreferrer"&gt;Kissmetrics&lt;/a&gt;, and &lt;a href="https://openalternative.co/alternatives/mixpanel" rel="noopener noreferrer"&gt;MixPanel&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Project Link: &lt;a href="https://openalternative.co/countly" rel="noopener noreferrer"&gt;&lt;strong&gt;Countly&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Swetrix
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Farfvem1qzm52w75g85bz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Farfvem1qzm52w75g85bz.png" alt="Swetrix Website Analytics" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This analytics tool is special because, unlike other tools, it provides additional information such as performance, sessions, errors, uptime, funnels, and alerts for your website.&lt;/p&gt;

&lt;p&gt;Swetrix keeps your data private and doesn't use cookies, unlike other tools. It is open source, so you can customize it to meet your needs. Whether you have a small website or a large online store, Swetrix makes analytics simple and clear.&lt;/p&gt;

&lt;p&gt;Swetrix is the best choice for monitoring and growing your business if you want to avoid using complicated tools.&lt;/p&gt;

&lt;p&gt;It's the best alternative for &lt;a href="https://openalternative.co/alternatives/fathom-analytics" rel="noopener noreferrer"&gt;Fathom Analytics&lt;/a&gt;, &lt;a href="https://openalternative.co/alternatives/google-analytics" rel="noopener noreferrer"&gt;Google Analytics&lt;/a&gt;, &lt;a href="https://openalternative.co/alternatives/mixpanel" rel="noopener noreferrer"&gt;MixPanel&lt;/a&gt;, and &lt;a href="https://openalternative.co/alternatives/kissmetrics" rel="noopener noreferrer"&gt;Kissmetrics&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Project Link: &lt;a href="https://openalternative.co/swetrix" rel="noopener noreferrer"&gt;&lt;strong&gt;Swetrix&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Matomo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1c5n9g3yfxp76e6wsp9t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1c5n9g3yfxp76e6wsp9t.png" alt="Matomo Website Analytics" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Matomo prioritizes the safety and confidentiality of user information while helping you understand how visitors interact with your website. Unlike other analytics platforms, Matomo gives you full ownership of your data, reducing any privacy concerns.&lt;/p&gt;

&lt;p&gt;It's user-friendly and doesn't rely on tracking cookies, making it easier to comply with privacy regulations. You can use Matomo to monitor visitor activity, gain insights into their interests, and make informed decisions to improve your website.&lt;/p&gt;

&lt;p&gt;If you want to grow your website without compromising privacy, Matomo is an excellent choice, trusted by over a million users. &lt;/p&gt;

&lt;p&gt;It's the best alternative for &lt;a href="https://openalternative.co/alternatives/fathom-analytics" rel="noopener noreferrer"&gt;Fathom Analytics&lt;/a&gt;, &lt;a href="https://openalternative.co/alternatives/google-analytics" rel="noopener noreferrer"&gt;Google Analytics&lt;/a&gt;, &lt;a href="https://openalternative.co/alternatives/google-tag-manager" rel="noopener noreferrer"&gt;Google Tag Manager&lt;/a&gt;, and &lt;a href="https://openalternative.co/alternatives/kissmetrics" rel="noopener noreferrer"&gt;Kissmetrics&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Project Link: &lt;a href="https://openalternative.co/matomo" rel="noopener noreferrer"&gt;&lt;strong&gt;Matomo&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Plausible
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F54xg0p6kdty8h8h758kr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F54xg0p6kdty8h8h758kr.png" alt="Plausible Website Analytics" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A straightforward and private solution for monitoring website traffic is Plausible Analytics. It doesn't use cookies, so there won't be any prominent banners, and it's quite light, so it won't slow down your website.&lt;/p&gt;

&lt;p&gt;While adhering to privacy regulations like the GDPR, you can know precisely where your traffic is coming from and how users are interacting with your website. It's simple to set up, open source, and even makes it easy to move away from Google Analytics.&lt;/p&gt;

&lt;p&gt;Plausible is the best option if you value privacy and want lucid insights.&lt;/p&gt;

&lt;p&gt;It's the best alternative for &lt;a href="https://openalternative.co/alternatives/fathom-analytics" rel="noopener noreferrer"&gt;Fathom Analytics&lt;/a&gt;, &lt;a href="https://openalternative.co/alternatives/google-analytics" rel="noopener noreferrer"&gt;Google Analytics&lt;/a&gt;, &lt;a href="https://openalternative.co/alternatives/google-workspace" rel="noopener noreferrer"&gt;Google Workspace&lt;/a&gt;, and &lt;a href="https://openalternative.co/alternatives/kissmetrics" rel="noopener noreferrer"&gt;Kissmetrics&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Project Link: &lt;a href="https://openalternative.co/plausible" rel="noopener noreferrer"&gt;&lt;strong&gt;Plausible&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Trench
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbrxhi6bf8grlsayz64qq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbrxhi6bf8grlsayz64qq.png" alt="Trench Website Analytics" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Trench is an open-source tool that makes it super easy to analyze and manage big data in real-time. This tool is only 3 months old and is supported by Y-Combinator.&lt;/p&gt;

&lt;p&gt;It is fast, scalable, and works well for tracking events and user actions on websites or apps. Built with strong technologies like Kafka and Clickhouse, it handles large data volumes effectively. Trench is GDPR-compliant, so you can trust it to protect your data privacy. You can choose to self-host it or use their cloud service, making it flexible for your needs.&lt;/p&gt;

&lt;p&gt;It is perfect for developers who want full control and powerful analytics.&lt;/p&gt;

&lt;p&gt;It's the best alternative for &lt;a href="https://openalternative.co/alternatives/june" rel="noopener noreferrer"&gt;June&lt;/a&gt;, &lt;a href="https://openalternative.co/alternatives/segment" rel="noopener noreferrer"&gt;Segment&lt;/a&gt;, &lt;a href="https://openalternative.co/alternatives/amplitude" rel="noopener noreferrer"&gt;Amplitude&lt;/a&gt;, &lt;a href="https://openalternative.co/alternatives/kissmetrics" rel="noopener noreferrer"&gt;Kissmetrics&lt;/a&gt;, &lt;a href="https://openalternative.co/alternatives/fullstory" rel="noopener noreferrer"&gt;Full Story&lt;/a&gt;, and &lt;a href="https://openalternative.co/alternatives/mixpanel" rel="noopener noreferrer"&gt;MixPanel&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Project Link: &lt;a href="https://openalternative.co/trench" rel="noopener noreferrer"&gt;&lt;strong&gt;Trench&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;I hope these open-source projects are useful to you. You can look into &lt;a href="https://openalternative.co" rel="noopener noreferrer"&gt;OpenAlternative&lt;/a&gt; if they don't meet your demands or if you're searching for other projects. It offers a wide range of free and open-source tools as substitutes for paid and expensive software out there.&lt;/p&gt;

&lt;p&gt;Thanks for reading!&lt;/p&gt;

&lt;p&gt;Written by &lt;a href="https://x.com/hii_mohit" rel="noopener noreferrer"&gt;Mohit Vaswani&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>software</category>
      <category>analytics</category>
      <category>productivity</category>
    </item>
    <item>
      <title>How to post a link with embed card on Bluesky with JavaScript</title>
      <dc:creator>Piotr Kulpinski</dc:creator>
      <pubDate>Wed, 11 Dec 2024 16:19:47 +0000</pubDate>
      <link>https://dev.to/piotrkulpinski/how-to-post-a-link-with-embed-card-on-bluesky-with-javascript-4bll</link>
      <guid>https://dev.to/piotrkulpinski/how-to-post-a-link-with-embed-card-on-bluesky-with-javascript-4bll</guid>
      <description>&lt;p&gt;As &lt;a href="https://bsky.app" rel="noopener noreferrer"&gt;Bluesky&lt;/a&gt; continues to gain popularity, more tools are being developed around it. One of the most popular applications is post scheduling and automation.&lt;/p&gt;

&lt;p&gt;However, Bluesky's API currently doesn't offer a direct way to post links with OpenGraph cards. This can be a challenge for users who want to share links with attractive previews.&lt;/p&gt;

&lt;p&gt;In this tutorial, I'll show you how to use JavaScript to post a link on Bluesky with a embed card. This method works around the API limitation, allowing you to share links more effectively.&lt;/p&gt;

&lt;p&gt;Let's get started!&lt;/p&gt;

&lt;h2&gt;
  
  
  Posting on Bluesky using JavaScript API
&lt;/h2&gt;

&lt;p&gt;Working with Bluesky API is pretty simple. The &lt;a href="https://docs.bsky.app/docs/get-started" rel="noopener noreferrer"&gt;docs&lt;/a&gt; are pretty good. First up, we need to install the &lt;code&gt;@atproto/api&lt;/code&gt; package from NPM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @atproto/api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, we create an instance of the Bluesky Agent and login with your Bluesky credencials.&lt;/p&gt;

&lt;p&gt;I recommend creating a new &lt;a href="https://bsky.app/settings/app-passwords" rel="noopener noreferrer"&gt;App Password&lt;/a&gt; for your Bluesky account, rather than using your main password. This will make it easier to revoke access if needed and keep your main account secure. Also make sure to set the &lt;code&gt;BLUESKY_USERNAME&lt;/code&gt; and &lt;code&gt;BLUESKY_PASSWORD&lt;/code&gt; environment variables in your project.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;AtpAgent&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@atproto/api&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getBlueskyAgent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AtpAgent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://bsky.social&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BLUESKY_USERNAME&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BLUESKY_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you have the agent, you can use it to post to Bluesky which is pretty straightforward.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Send a post to Bluesky
 * @param text - The text of the post
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sendBlueskyPost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getBlueskyAgent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And there you have it, you just sent a post to Bluesky. Unfortunately, even if you include a link in the text of your post, it isn't automatically converted into an anchor link. We'll fix that shortly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detect Faceted Links on Bluesky Automatically
&lt;/h2&gt;

&lt;p&gt;When you include a link in your post text on Bluesky, it isn't automatically turned into an anchor link. Instead, it appears as plain text.&lt;/p&gt;

&lt;p&gt;To fix this, you need to detect the links and convert them into faceted links.&lt;/p&gt;

&lt;p&gt;While there are manual methods to achieve this, fortunately, ATProto provides a &lt;code&gt;RichText&lt;/code&gt; class that can automatically detect links and convert them into faceted links.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;RichText&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@atproto/api&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * Send a post to Bluesky
 * @param text - The text of the post
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sendBlueskyPost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getBlueskyAgent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RichText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;rt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;detectFacets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;facets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;facets&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's great, but we still need to add the embed card to the post. Let's do that next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating an Embed Card on Bluesky
&lt;/h2&gt;

&lt;p&gt;Including a link in your post is great, but it's even better if you can add a embed card.&lt;/p&gt;

&lt;p&gt;To achieve this, we need to use the &lt;a href="https://docs.bsky.app/docs/advanced-guides/posts#website-card-embeds" rel="noopener noreferrer"&gt;Website card embed&lt;/a&gt; feature of Bluesky. Essentially, you add an embed key to your post that includes, at a minimum, a URL, title, and description.&lt;/p&gt;

&lt;p&gt;There are several ways to obtain the required data. If you know it at the time of posting, you can simply hardcode it. Otherwise, you can scrape the URL to gather the title, description, and image.&lt;/p&gt;

&lt;p&gt;However, I find the easiest way is to use the &lt;a href="https://dub.co/tools/metatags" rel="noopener noreferrer"&gt;Dub.co Metatags API&lt;/a&gt; to fetch the URL metadata and then create the embed card from that. Let's see how that works.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * Get the URL metadata
 * @param url - The URL to get the metadata for
 * @returns The metadata
 */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getUrlMetadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://api.dub.co/metatags?url=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We created a simple function that fetches the URL metadata and then returns the data in a clear format.&lt;/p&gt;

&lt;p&gt;Next, let's create a function that uses the metadata to upload the image to Bluesky and then create the embed card.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getBlueskyEmbedCard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AtpAgent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getUrlMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uploadBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;encoding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;$type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;app.bsky.embed.external&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;thumb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Error fetching embed card:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once we have the embed card, we can add it to the post.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sendBlueskyPost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getBlueskyAgent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RichText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;rt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;detectFacets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;facets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;facets&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getBlueskyEmbedCard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now we have a function that sends a post to Bluesky with an embed card.&lt;/p&gt;

&lt;h2&gt;
  
  
  Complete Example
&lt;/h2&gt;

&lt;p&gt;Hopefully, if you have followed along, you should have a complete code by now. If not, here is the complete code that you can copy and paste into your project. It:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Creates a Bluesky agent&lt;/li&gt;
&lt;li&gt;Fetches the URL metadata&lt;/li&gt;
&lt;li&gt;Creates an embed card&lt;/li&gt;
&lt;li&gt;Sends a post to Bluesky with the embed card and automatically detects faceted links
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;AtpAgent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;RichText&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@atproto/api&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * Get the URL metadata
 * @param url - The URL to get the metadata for
 * @returns The metadata
 */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getUrlMetadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://api.dub.co/metatags?url=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * Get the Bluesky embed card
 * @param url - The URL to get the embed card for
 * @param agent - The Bluesky agent
 * @returns The embed card
 */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getBlueskyEmbedCard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AtpAgent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getUrlMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uploadBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;encoding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;$type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;app.bsky.embed.external&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;thumb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Error fetching embed card:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * Get the Bluesky agent
 * @returns The Bluesky agent
 */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getBlueskyAgent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AtpAgent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://bsky.social&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BLUESKY_USERNAME&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BLUESKY_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * Send a post to Bluesky
 * @param text - The text of the post
 * @param url - The URL to include in the post
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sendBlueskyPost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getBlueskyAgent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RichText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;rt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;detectFacets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;facets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;facets&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getBlueskyEmbedCard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I hope you found this tutorial helpful and that you will consider using it in your own projects.&lt;/p&gt;

&lt;p&gt;Happy posting!&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>atprotocol</category>
      <category>bluesky</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Top 5 Open-Source Projects for Developers</title>
      <dc:creator>Piotr Kulpinski</dc:creator>
      <pubDate>Wed, 04 Dec 2024 14:19:29 +0000</pubDate>
      <link>https://dev.to/piotrkulpinski/top-5-open-source-projects-for-developers-477n</link>
      <guid>https://dev.to/piotrkulpinski/top-5-open-source-projects-for-developers-477n</guid>
      <description>&lt;p&gt;If you’re a developer looking to improve your productivity, check out some outstanding open-source projects I’ve found. They can enhance your skills and streamline your workflow effectively.&lt;/p&gt;

&lt;p&gt;These projects are made by skilled developers for the entire community. Whether you’re just starting or have years of experience, there’s something for you.&lt;/p&gt;

&lt;p&gt;They will help you with a variety of tasks like —&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Track, improve, and build better apps.&lt;/li&gt;
&lt;li&gt;  Find and Fix code errors faster than ever.&lt;/li&gt;
&lt;li&gt;  Connect and organize your data for smarter decisions.&lt;/li&gt;
&lt;li&gt;  See and fix issues in your app while keeping your data secure.&lt;/li&gt;
&lt;li&gt;  Make your website analytics much easier and shareable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let’s take a look at the top five projects in detail.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. PostHog
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff7bp1v3xxd9xo4acy1vh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff7bp1v3xxd9xo4acy1vh.png" alt="Posthog og image" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This project is a secret weapon for developers who want to build awesome websites and apps that users love to use.&lt;/p&gt;

&lt;p&gt;It helps you track what people are doing on your website, find ways to make it better and fix problems quickly. All of your data is in one place, and you have complete control over it.&lt;/p&gt;

&lt;p&gt;It’s super powerful, easy to use, and makes building successful &amp;amp; scalable apps feel easy.&lt;/p&gt;

&lt;p&gt;It’s the best alternative for &lt;a href="https://openalternative.co/alternatives/fathom-analytics" rel="noopener noreferrer"&gt;&lt;strong&gt;Fathom Analytics&lt;/strong&gt;&lt;/a&gt;, &lt;a href="https://openalternative.co/alternatives/segment" rel="noopener noreferrer"&gt;&lt;strong&gt;Segment&lt;/strong&gt;&lt;/a&gt;, &lt;a href="https://openalternative.co/alternatives/amplitude" rel="noopener noreferrer"&gt;&lt;strong&gt;Amplitude&lt;/strong&gt;&lt;/a&gt;, &lt;a href="https://openalternative.co/alternatives/optimizely" rel="noopener noreferrer"&gt;&lt;strong&gt;Optimizely&lt;/strong&gt;&lt;/a&gt;, &lt;a href="https://openalternative.co/alternatives/june" rel="noopener noreferrer"&gt;&lt;strong&gt;June&lt;/strong&gt;&lt;/a&gt;, and &lt;a href="https://openalternative.co/alternatives/mixpanel" rel="noopener noreferrer"&gt;&lt;strong&gt;MixPanel&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Many big companies are satisfied customers of Posthog.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project Link:&lt;/strong&gt; &lt;a href="https://openalternative.co/posthog" rel="noopener noreferrer"&gt;Posthog&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Sentry
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe2jalpz3521e5zydfl4w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe2jalpz3521e5zydfl4w.png" alt="Sentry og image" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When your app has bugs, it can be frustrating and time-consuming to fix them. But with Sentry, you can resolve errors quickly and easily.&lt;/p&gt;

&lt;p&gt;It helps you find what went wrong in your codebase and shows you how to fix it. You can track issues, replay user sessions, and even see what your users are experiencing — all in one place.&lt;/p&gt;

&lt;p&gt;It’s like having an expert programmer for your coding. You won’t need to guess or waste time anymore. You can create clean and effective websites faster than ever.&lt;/p&gt;

&lt;p&gt;It’s the best alternative for &lt;a href="https://openalternative.co/alternatives/datadog" rel="noopener noreferrer"&gt;&lt;strong&gt;Datadog&lt;/strong&gt;&lt;/a&gt;, &lt;a href="https://openalternative.co/alternatives/betterstack" rel="noopener noreferrer"&gt;&lt;strong&gt;BetterStack&lt;/strong&gt;&lt;/a&gt; and &lt;a href="https://openalternative.co/alternatives/logrocket" rel="noopener noreferrer"&gt;&lt;strong&gt;LogRocket&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project Link&lt;/strong&gt;: &lt;a href="https://openalternative.co/sentry" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Airbyte
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgg04nntb8yzsxo0j8t7b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgg04nntb8yzsxo0j8t7b.png" alt="Airbyte og image" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This project helps you make your data more useful, no matter where it is stored. It connects data from different apps and platforms, allowing you to use it in smart ways, such as enhancing AI or improving your business.&lt;/p&gt;

&lt;p&gt;It’s open-source, so anyone can use it. Also, it’s easy to set up, secure and works with tons of tools.&lt;/p&gt;

&lt;p&gt;Whether you’re a data wizard or just starting out, this makes managing and using your data a breeze.&lt;/p&gt;

&lt;p&gt;It’s the best alternative for &lt;a href="https://openalternative.co/alternatives/fivetran" rel="noopener noreferrer"&gt;&lt;strong&gt;Fivetran&lt;/strong&gt;&lt;/a&gt;, &lt;a href="https://openalternative.co/alternatives/matillion" rel="noopener noreferrer"&gt;&lt;strong&gt;Matillion&lt;/strong&gt;&lt;/a&gt;, and &lt;a href="https://openalternative.co/alternatives/supermetrics" rel="noopener noreferrer"&gt;&lt;strong&gt;Supermetrics&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project Link:&lt;/strong&gt; &lt;a href="https://openalternative.co/airbyte" rel="noopener noreferrer"&gt;Airbyte&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  4. OpenReplay
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftp8rp3idz56gxrgptpf7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftp8rp3idz56gxrgptpf7.png" alt="OpenReplay og image" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;OpenReplay is like a time machine for your apps. It lets you watch exactly what your users are doing on your website or app — like a replay of their sessions.&lt;/p&gt;

&lt;p&gt;You can find and fix bugs to make your website run better. The best part is that you can host it yourself, keeping all your data safe and private. It has great features like product analytics, DevTools, and co-browsing to help users in real time.&lt;/p&gt;

&lt;p&gt;Honestly, it’s like having a perfect tool to make your app the best.&lt;/p&gt;

&lt;p&gt;It’s the best alternative for &lt;a href="https://openalternative.co/alternatives/mixpanel" rel="noopener noreferrer"&gt;&lt;strong&gt;Mixpanel&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;,&lt;/strong&gt; &lt;a href="https://openalternative.co/alternatives/new-relic" rel="noopener noreferrer"&gt;&lt;strong&gt;New Relic&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;,&lt;/strong&gt; &lt;a href="https://openalternative.co/alternatives/betterstack" rel="noopener noreferrer"&gt;&lt;strong&gt;BetterStack&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;,&lt;/strong&gt; &lt;a href="https://openalternative.co/alternatives/kissmetrics" rel="noopener noreferrer"&gt;&lt;strong&gt;Kissmetrics&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;,&lt;/strong&gt; &lt;a href="https://openalternative.co/alternatives/amplitude" rel="noopener noreferrer"&gt;&lt;strong&gt;Amplitude&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;,&lt;/strong&gt; &lt;a href="https://openalternative.co/alternatives/june" rel="noopener noreferrer"&gt;&lt;strong&gt;June&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;,&lt;/strong&gt; &lt;a href="https://openalternative.co/alternatives/logrocket" rel="noopener noreferrer"&gt;&lt;strong&gt;LogRocket&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;,&lt;/strong&gt; and &lt;a href="https://openalternative.co/alternatives/fullstory" rel="noopener noreferrer"&gt;&lt;strong&gt;FullStory&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project Link:&lt;/strong&gt; &lt;a href="https://openalternative.co/openreplay" rel="noopener noreferrer"&gt;OpenReplay&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Plausible
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzixf8it5uddot4h5yi4j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzixf8it5uddot4h5yi4j.png" alt="Plausible og image" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Plausible is the cool, privacy-friendly analytics your website needs. It’s a perfect alternative to Google Analytics that shows you all the necessary stats about your site — like visitors, clicks, and where people are coming from.&lt;/p&gt;

&lt;p&gt;It’s lightweight, so it won’t slow down your site, and it doesn’t use cookies or need those unnecessary consent banners. Also, it’s open-source, so you can trust it’s safe and transparent.&lt;/p&gt;

&lt;p&gt;If you care about privacy and want clean, no-fuss data, Plausible is your perfect match!&lt;/p&gt;

&lt;p&gt;It’s the best alternative for &lt;a href="https://openalternative.co/alternatives/mixpanel" rel="noopener noreferrer"&gt;&lt;strong&gt;Mixpanel&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;,&lt;/strong&gt; &lt;a href="https://openalternative.co/alternatives/google-analytics" rel="noopener noreferrer"&gt;&lt;strong&gt;Google Analytics&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;,&lt;/strong&gt; &lt;a href="https://openalternative.co/alternatives/kissmetrics" rel="noopener noreferrer"&gt;&lt;strong&gt;Kissmetrics&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;,&lt;/strong&gt; &lt;a href="https://openalternative.co/alternatives/fathom-analytics" rel="noopener noreferrer"&gt;&lt;strong&gt;Fathom Analytics&lt;/strong&gt;&lt;/a&gt; and &lt;a href="https://openalternative.co/alternatives/google-workspace" rel="noopener noreferrer"&gt;&lt;strong&gt;Google Workspace&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project Link:&lt;/strong&gt; &lt;a href="https://openalternative.co/plausible" rel="noopener noreferrer"&gt;Plausible&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;I hope you find these open-source projects helpful. If they don’t fit your needs or if you’re looking for more projects, you can explore &lt;a href="https://openalternative.co/" rel="noopener noreferrer"&gt;&lt;strong&gt;OpenAlternative&lt;/strong&gt;&lt;/a&gt;. It includes a variety of open-source alternative tools to paid software.&lt;/p&gt;

&lt;p&gt;Thanks for reading!&lt;/p&gt;




&lt;p&gt;Written by &lt;a href="https://x.com/hii_mohit" rel="noopener noreferrer"&gt;Mohit Vaswani&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>programming</category>
      <category>software</category>
      <category>github</category>
    </item>
    <item>
      <title>Supercharge Your Number Inputs with Custom Step Sizes</title>
      <dc:creator>Piotr Kulpinski</dc:creator>
      <pubDate>Fri, 26 Apr 2024 21:33:41 +0000</pubDate>
      <link>https://dev.to/piotrkulpinski/supercharge-your-number-inputs-with-custom-step-sizes-mlh</link>
      <guid>https://dev.to/piotrkulpinski/supercharge-your-number-inputs-with-custom-step-sizes-mlh</guid>
      <description>&lt;p&gt;The &lt;code&gt;&amp;lt;input type="number"&amp;gt;&lt;/code&gt; element provides a convenient way to handle numeric input in web forms. You can set bounds with the &lt;code&gt;min&lt;/code&gt; and &lt;code&gt;max&lt;/code&gt; attributes, and users can press the up and down arrow keys to increment or decrement the value by the specified &lt;code&gt;step&lt;/code&gt; size. However, what if you want to allow users to adjust the value using different step sizes?&lt;/p&gt;

&lt;p&gt;By default, the &lt;code&gt;step&lt;/code&gt; attribute not only determines the increment/decrement amount but also clips the value to the nearest multiple of the step. For example, if you have an input with a value of 5 and a step of 10, pressing the up arrow key will set the value to 10 (the nearest multiple of the step) instead of 15 (5 + 10).&lt;/p&gt;

&lt;h2&gt;
  
  
  How to build a better number input
&lt;/h2&gt;

&lt;p&gt;In this article, we'll explore how to enhance the functionality of number inputs to support custom step sizes. We'll implement the following features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;↑/↓&lt;/code&gt; – adjusts the value by the specified &lt;code&gt;step&lt;/code&gt; size (1 if step is not specified).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Shift&lt;/code&gt;+&lt;code&gt;↑/↓&lt;/code&gt; – adjusts the value by step &lt;strong&gt;multiplied by 10&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Alt&lt;/code&gt;+&lt;code&gt;↑/↓&lt;/code&gt; – adjusts the value by step &lt;strong&gt;divided by 10&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Ctrl/Cmd&lt;/code&gt;+&lt;code&gt;↑/↓&lt;/code&gt; – adjusts the value by step &lt;strong&gt;multiplied by 100&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;If the input is empty, the value is calculated based on the &lt;code&gt;min&lt;/code&gt; attribute (0 if &lt;code&gt;min&lt;/code&gt; is not specified).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let's dive into the implementation!&lt;/p&gt;

&lt;h2&gt;
  
  
  Helper Functions
&lt;/h2&gt;

&lt;p&gt;First, let's define some utility functions that we'll use throughout the implementation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parseNumericValue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;parseNumericValue&lt;/code&gt; function takes a value (which can be a string, number, or null) and parses it as a floating-point number. If the value is &lt;code&gt;undefined&lt;/code&gt; or &lt;code&gt;null&lt;/code&gt;, it returns &lt;code&gt;undefined&lt;/code&gt;. If the parsing fails, it also returns &lt;code&gt;undefined&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This function will be useful for parsing the &lt;code&gt;min&lt;/code&gt;, &lt;code&gt;max&lt;/code&gt;, &lt;code&gt;step&lt;/code&gt;, and current value of the input elements.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;preciseRound&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;decimals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;factor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;decimals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EPSILON&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;factor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;factor&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;preciseRound&lt;/code&gt; function rounds a number to a specified number of decimal places. It uses the &lt;code&gt;Number.EPSILON&lt;/code&gt; constant to handle &lt;a href="https://blog.demofox.org/2017/11/21/floating-point-precision/"&gt;floating-point precision issues&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;keepNumberInRange&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;min&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;min&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;min&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;min&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;min&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;keepNumberInRange&lt;/code&gt; function ensures that a given number stays within the specified &lt;code&gt;min&lt;/code&gt; and &lt;code&gt;max&lt;/code&gt; bounds. I like to use reusable utility functions like this to keep the code clean and maintainable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementing the Key Up Handler
&lt;/h2&gt;

&lt;p&gt;Here's the main event handler function that will be called when the user presses the up or down arrow keys on a number input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;KeyboardEvent&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleKeyUp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;KeyboardEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLInputElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ArrowUp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ArrowDown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTarget&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;direction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ArrowUp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;min&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseNumericValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;min&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseNumericValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;max&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseNumericValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;step&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseNumericValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;min&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;increment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metaKey&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shiftKey&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;altKey&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newValue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;preciseRound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;direction&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newValue&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;keepNumberInRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;min&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Usage: &amp;lt;input type="number" onKeyUp={handleKeyUp} /&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's break down the code:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;We check if the pressed key is either "ArrowUp" or "ArrowDown". If not, we return early.&lt;/li&gt;
&lt;li&gt;We get the current target input element and determine the direction based on the pressed key (+1 for "ArrowUp", -1 for "ArrowDown").&lt;/li&gt;
&lt;li&gt;We parse the &lt;code&gt;min&lt;/code&gt;, &lt;code&gt;max&lt;/code&gt;, and &lt;code&gt;step&lt;/code&gt; attributes of the input element using the &lt;code&gt;parseNumericValue&lt;/code&gt; utility function.&lt;/li&gt;
&lt;li&gt;We parse the current value of the input using &lt;code&gt;parseNumericValue&lt;/code&gt; or fallback to the &lt;code&gt;min&lt;/code&gt; value or 0 if the input is empty.&lt;/li&gt;
&lt;li&gt;We calculate the increment based on the modifier keys: Ctrl/Cmd multiplies the step by 100, Shift multiplies it by 10, Alt divides it by 10, and the default is 1.&lt;/li&gt;
&lt;li&gt;We calculate the new value by adding the product of the direction and the increment to the current value, using the &lt;code&gt;preciseRound&lt;/code&gt; utility function to handle floating-point precision.&lt;/li&gt;
&lt;li&gt;If the new value is different from the current value, we update the input value using the &lt;code&gt;setInputValue&lt;/code&gt; function, ensuring it stays within the specified &lt;code&gt;min&lt;/code&gt; and &lt;code&gt;max&lt;/code&gt; range.&lt;/li&gt;
&lt;li&gt;Finally, we call &lt;code&gt;e.preventDefault()&lt;/code&gt; to prevent the default browser behavior.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With this implementation, your number inputs will have the desired supercharged behavior, allowing users to quickly adjust values using different step sizes based on the modifier keys they press.&lt;/p&gt;

&lt;p&gt;You can easily integrate this code into your project by attaching the &lt;code&gt;handleKeyUp&lt;/code&gt; event handler to your number input elements.&lt;/p&gt;

&lt;p&gt;Happy coding!&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>ux</category>
      <category>beginners</category>
    </item>
    <item>
      <title>How to Build Dynamic Breadcrumbs in Remix</title>
      <dc:creator>Piotr Kulpinski</dc:creator>
      <pubDate>Fri, 29 Mar 2024 20:30:46 +0000</pubDate>
      <link>https://dev.to/piotrkulpinski/how-to-build-dynamic-breadcrumbs-in-remix-1nid</link>
      <guid>https://dev.to/piotrkulpinski/how-to-build-dynamic-breadcrumbs-in-remix-1nid</guid>
      <description>&lt;p&gt;In Remix, building dynamic breadcrumbs that reflect your route hierarchy is straightforward. This tutorial will guide you through leveraging the &lt;code&gt;useMatches&lt;/code&gt; and &lt;code&gt;handle&lt;/code&gt; capabilities to achieve this.&lt;/p&gt;

&lt;p&gt;We'll also cover how to add schema metadata to your breadcrumbs for better SEO and social sharing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Basics
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Breadcrumbs&lt;/strong&gt; are a navigation aid that helps users understand their current location within a website. They typically appear horizontally at the top of a page and show the hierarchy of the current page in relation to the site structure.&lt;/p&gt;

&lt;p&gt;Here's an example of what breadcrumbs code could look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;ol&lt;/span&gt; &lt;span class="na"&gt;itemscope&lt;/span&gt; &lt;span class="na"&gt;itemtype=&lt;/span&gt;&lt;span class="s"&gt;"https://schema.org/BreadcrumbList"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;li&lt;/span&gt; &lt;span class="na"&gt;itemprop=&lt;/span&gt;&lt;span class="s"&gt;"itemListElement"&lt;/span&gt; &lt;span class="na"&gt;itemscope&lt;/span&gt; &lt;span class="na"&gt;itemtype=&lt;/span&gt;&lt;span class="s"&gt;"https://schema.org/ListItem"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/"&lt;/span&gt; &lt;span class="na"&gt;itemprop=&lt;/span&gt;&lt;span class="s"&gt;"item"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;itemprop=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Home&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;itemprop=&lt;/span&gt;&lt;span class="s"&gt;"position"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/li&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;li&lt;/span&gt; &lt;span class="na"&gt;itemprop=&lt;/span&gt;&lt;span class="s"&gt;"itemListElement"&lt;/span&gt; &lt;span class="na"&gt;itemscope&lt;/span&gt; &lt;span class="na"&gt;itemtype=&lt;/span&gt;&lt;span class="s"&gt;"https://schema.org/ListItem"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/posts"&lt;/span&gt; &lt;span class="na"&gt;itemprop=&lt;/span&gt;&lt;span class="s"&gt;"item"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;itemprop=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Posts&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;itemprop=&lt;/span&gt;&lt;span class="s"&gt;"position"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"2"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/li&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;li&lt;/span&gt; &lt;span class="na"&gt;itemprop=&lt;/span&gt;&lt;span class="s"&gt;"itemListElement"&lt;/span&gt; &lt;span class="na"&gt;itemscope&lt;/span&gt; &lt;span class="na"&gt;itemtype=&lt;/span&gt;&lt;span class="s"&gt;"https://schema.org/ListItem"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/posts/slug"&lt;/span&gt; &lt;span class="na"&gt;itemprop=&lt;/span&gt;&lt;span class="s"&gt;"item"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;itemprop=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Post Title&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;itemprop=&lt;/span&gt;&lt;span class="s"&gt;"position"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"3"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/li&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/ol&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, let's see how to build dynamic breadcrumbs in Remix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defining the Breadcrumbs Components
&lt;/h2&gt;

&lt;p&gt;Let's start by creating the necessary component that we'll later use to render the breadcrumbs.&lt;/p&gt;

&lt;p&gt;We'll need a &lt;code&gt;Breadcrumbs&lt;/code&gt; component to render the list of breadcrumbs and a &lt;code&gt;BreadcrumbsItem&lt;/code&gt; component to render each breadcrumb item.&lt;/p&gt;

&lt;p&gt;Start by defining a wrapper component that will list all of the breadcrumbs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/components/Breadcrumbs.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;UIMatch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useMatches&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@remix-run/react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Fragment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;HTMLAttributes&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;BreadcrumbMatch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;UIMatch&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;
  &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;breadcrumb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;JSX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Element&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Breadcrumbs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;HTMLAttributes&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;matches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;useMatches&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;BreadcrumbMatch&lt;/span&gt;&lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;handle&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;breadcrumb&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;ol&lt;/span&gt;
      &lt;span class="na"&gt;itemScope&lt;/span&gt;
      &lt;span class="na"&gt;itemType&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"https://schema.org/BreadcrumbList"&lt;/span&gt;
      &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex flex-wrap items-center gap-2.5"&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Fragment&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt;
            &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"contents"&lt;/span&gt;
            &lt;span class="na"&gt;itemProp&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"itemListElement"&lt;/span&gt;
            &lt;span class="na"&gt;itemScope&lt;/span&gt;
            &lt;span class="na"&gt;itemType&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"https://schema.org/ListItem"&lt;/span&gt;
          &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-sm"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;/&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;breadcrumb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;meta&lt;/span&gt; &lt;span class="na"&gt;itemProp&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"position"&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Fragment&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;ol&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the example above, we're using the &lt;code&gt;useMatches&lt;/code&gt; hook to get the current route matches. We then filter the matches to only include those that have a &lt;code&gt;breadcrumb&lt;/code&gt; handle. We will define this handle in the routes where we want to include a breadcrumb. We then iterate over the matches and render the breadcrumb component for each match.&lt;/p&gt;

&lt;p&gt;You may have noticed that we're also passing a &lt;code&gt;data&lt;/code&gt; prop to the &lt;code&gt;handle.breadcrumb&lt;/code&gt; function. This is because the &lt;code&gt;breadcrumb&lt;/code&gt; handle can accept data from the route loader. We'll see how to pass data to the breadcrumb handle in the next section.&lt;/p&gt;

&lt;p&gt;Next, let's define the &lt;code&gt;BreadcrumbsItem&lt;/code&gt; component that we'll use to render each breadcrumb item.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/components/BreadcrumbsItem.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Link&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@remix-run/react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;HTMLAttributes&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BreadcrumbsItem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;HTMLAttributes&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;itemProp&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"item"&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;itemProp&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now that we have our components, let's define the breadcrumbs in our routes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defining the Breadcrumbs handles in the Routes
&lt;/h2&gt;

&lt;p&gt;In the routes where you want to include breadcrumbs, you need to define a &lt;code&gt;breadcrumb&lt;/code&gt; handle that will render the breadcrumb item.&lt;/p&gt;

&lt;p&gt;For example, let's define the following routes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/&lt;/code&gt; - Home&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/posts&lt;/code&gt; - Posts&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/posts/$slug&lt;/code&gt; - Post Details
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/routes/index.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BreadcrumbsItem&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;~/components/BreadcrumbsItem&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;breadcrumb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;BreadcrumbsItem&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Home&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;BreadcrumbsItem&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Index&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Home&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/routes/posts/index.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BreadcrumbsItem&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;~/components/BreadcrumbsItem&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;breadcrumb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;BreadcrumbsItem&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/posts"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Posts&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;BreadcrumbsItem&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Posts&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Posts&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/routes/posts.$slug.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;LoaderFunctionArgs&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@remix-run/node&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useLoaderData&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@remix-run/react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BreadcrumbsItem&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;~/components/BreadcrumbsItem&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;breadcrumb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;BreadcrumbsItem&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`/posts/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;BreadcrumbsItem&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;LoaderFunctionArgs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Example Post&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;example-post&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useLoaderData&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;loader&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the last route, we're using the &lt;code&gt;loader&lt;/code&gt; function to fetch the post data. We then pass this data to the &lt;code&gt;breadcrumb&lt;/code&gt; handle to render the breadcrumb item. This allows us to dynamically generate the breadcrumb item based on the post data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Hopefully, this guide has helped you understand how to implement breadcrumbs in your Remix application.&lt;/p&gt;

&lt;p&gt;If you're interested to see how breadcrumbs can be implemented in a real-world application, you can check out the &lt;a href="https://openalternative.co"&gt;OpenAlternative&lt;/a&gt; project, or check out the &lt;a href="https://github.com/piotrkulpinski/openalternative"&gt;source code&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you have any questions or need further clarification, feel free to reach out to me on &lt;a href="https://twitter.com/piotrkulpinski"&gt;Twitter&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>breadcrumbs</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>remix</category>
    </item>
    <item>
      <title>Growing a side-project to 100k Unique Visitors in one week</title>
      <dc:creator>Piotr Kulpinski</dc:creator>
      <pubDate>Tue, 19 Mar 2024 21:43:24 +0000</pubDate>
      <link>https://dev.to/piotrkulpinski/growing-a-side-project-to-100k-unique-visitors-in-one-week-29mf</link>
      <guid>https://dev.to/piotrkulpinski/growing-a-side-project-to-100k-unique-visitors-in-one-week-29mf</guid>
      <description>&lt;p&gt;I'm a software engineer by trade, but I've always been interested in marketing and growth. I've been working on a various side projects for a while now, and I recently hit a major success: &lt;strong&gt;100,000 unique visitors in one week&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here's how I did it 👇&lt;/p&gt;

&lt;p&gt;&lt;a href="https://twitter.com/piotrkulpinski/status/1767108966603980866" rel="noopener noreferrer"&gt;https://twitter.com/piotrkulpinski/status/1767108966603980866&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Idea
&lt;/h2&gt;

&lt;p&gt;I was collecting a list of open-source software for a while. Mostly for my own use (to learn and reuse parts of the code), but I thought it would be cool to share it with others. I was also interested in learning more about SEO and growth, so I thought this would be a good project to experiment with.&lt;/p&gt;

&lt;p&gt;I didn't want to spend a lot of time on it, so I decided to build it within &lt;strong&gt;48 hours&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;With such a short time frame, I had to be very selective about the features I wanted to include. I decided to build a simple listing with no search or filtering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Curating the list
&lt;/h2&gt;

&lt;p&gt;I started by expanding the list of open-source software. I used a combination of Google, Reddit, and GitHub to find the most popular open-source projects. I also used my own knowledge and experience to find lesser-known projects that I thought were worth including.&lt;/p&gt;

&lt;p&gt;I didn't want to include every open-source project I could find. I wanted to keep the list high-quality and focused on the most popular, useful software and actively maintained projects. I &lt;strong&gt;collected about 70 projects&lt;/strong&gt; in total.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the site
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://astro.build" rel="noopener noreferrer"&gt;Astro&lt;/a&gt; was always on my list of things to learn. I've been using Remix and NextJS for a while, and I was interested in trying out a new framework. I decided it would be a good opportunity to build the site with it. This decision turned out to be a great one, as it saved me a lot of money on hosting costs later on.&lt;/p&gt;

&lt;p&gt;While browsing the Astro documentation, I found out they have a pretty easy way to implement a &lt;a href="https://developer.chrome.com/docs/web-platform/view-transitions" rel="noopener noreferrer"&gt;View Transitions&lt;/a&gt; so I tought it would be a nice touch to the site.&lt;/p&gt;

&lt;p&gt;For the backend, I opted for &lt;a href="https://airtable.com" rel="noopener noreferrer"&gt;Airtable&lt;/a&gt; as a database. It's a simple, no-code solution that I've used before. It's not the most powerful database, but it's perfect for a project like this. I could easily add, edit, and delete records, and it has an embeddable form functionality that I used for user submissions.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft2ccyyspbi9icgep30sa.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft2ccyyspbi9icgep30sa.jpg" width="800" height="429"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Unfortunately, Airtable's API isn't the best for querying data, so I used a &lt;a href="https://baseql.com" rel="noopener noreferrer"&gt;middleware service&lt;/a&gt; to turn the Airtable API into a GraphQL API. This allowed me to query the data in a more flexible way.&lt;/p&gt;

&lt;p&gt;With the data and the site structure in place, I started building the site. I used Tailwind CSS for the styling, and I was able to build the site in about &lt;strong&gt;12 hours&lt;/strong&gt;. The good thing about having multiple projects under my belt is that I can reuse a lot of the code I've written before.&lt;/p&gt;

&lt;p&gt;Building an Open Source listing, it was pretty obvious that I should open-source &lt;a href="https://github.com/piotrkulpinski/openalternative" rel="noopener noreferrer"&gt;the code&lt;/a&gt; as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub Data and Programmatic SEO
&lt;/h2&gt;

&lt;p&gt;To make the site more useful, I decided to include some data from GitHub. I've built a scheduled Cloudflare Worker that's pulling the GitHub API data to fetch the number of stars, forks, and issues for each project.&lt;/p&gt;

&lt;p&gt;I also pulled some related tags from each repo, like what programming language it's written in, and what it's related to (e.g. CMS, CRM, etc.). I then put this data into Airtable and used it to generate lots of pages for &lt;strong&gt;Programmatic SEO&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It's too early to tell if this will have a big impact on SEO, but I think it's a good idea to have a lot of pages for Google to index. It's already ranking for some very competitive keywords like "open source alternatives" so I think it may work pretty well.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx11ulk1m094vv67ykn09.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx11ulk1m094vv67ykn09.png" width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Hosting
&lt;/h2&gt;

&lt;p&gt;Since Astro is a static site generator, I could host the site for free on &lt;a href="https://cloudflare.com" rel="noopener noreferrer"&gt;Cloudflare&lt;/a&gt;. I've never used Cloudflare before, but they've been pretty popular lately due to their free hosting and CDN. I was impressed with how easy it was to set up, and the performance was great.&lt;/p&gt;

&lt;p&gt;I then picked the simplest name I could find and registered &lt;a href="https://openalternative.co" rel="noopener noreferrer"&gt;openalternative.co&lt;/a&gt; for $6.99.&lt;/p&gt;

&lt;h2&gt;
  
  
  Launch(es)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Twitter
&lt;/h3&gt;

&lt;p&gt;Like always, I posted about it on &lt;a href="https://twitter.com/piotrkulpinski/status/1764561508028326128" rel="noopener noreferrer"&gt;Twitter&lt;/a&gt;. Given that I only had 900 followers and I usually tweet to the void, I didn't expect much. But to my surprise, the tweet got some attention. Nothing crazy, but it was a good start.&lt;/p&gt;

&lt;p&gt;For the next few days I let it sit and see what happens. I was engaging with people on Twitter to get some feedback. One of such conversations was with &lt;a href="https://twitter.com/steventey" rel="noopener noreferrer"&gt;Steven Tey&lt;/a&gt; who seemed to like the idea and decided to share it with his audience.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://twitter.com/steventey/status/1765841867017437599" rel="noopener noreferrer"&gt;https://twitter.com/steventey/status/1765841867017437599&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This was a huge boost for the site, as it got a lot of traffic from his tweet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Product Hunt
&lt;/h3&gt;

&lt;p&gt;I knew I had to keep the momentum going, so I decided to post it on &lt;a href="https://www.producthunt.com/posts/openalternative" rel="noopener noreferrer"&gt;Product Hunt&lt;/a&gt; the next day.&lt;/p&gt;

&lt;p&gt;I still had some nice traffic to the site, so I've added a &lt;strong&gt;custom PH banner&lt;/strong&gt; to the top of the site to let people know about the launch. I also posted about it on Twitter and LinkedIn.&lt;/p&gt;

&lt;p&gt;With the help of the community, we were able to get &lt;strong&gt;3rd place&lt;/strong&gt; on the day of the launch with over 500 upvotes. This was one of the most successful PH launches I've ever had.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fncozbblfxmaj8780frsk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fncozbblfxmaj8780frsk.png" width="800" height="262"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Hacker News
&lt;/h3&gt;

&lt;p&gt;Knowing that the site was getting some attention, I decided to post it on &lt;a href="https://news.ycombinator.com/item?id=39639386" rel="noopener noreferrer"&gt;Hacker News&lt;/a&gt; as well.&lt;/p&gt;

&lt;p&gt;I had a feeling that it would do well on HN, as I thought it would be a good fit for the community, but I didn't expect it to get this good.&lt;/p&gt;

&lt;p&gt;The post immediately got featured on to the front page and quickly climbed to claim the &lt;strong&gt;#1 spot on the site&lt;/strong&gt;. It got over 150 upvotes and stayed on the front page for a few hours.&lt;/p&gt;

&lt;p&gt;Being featured on Product Hunt is great, but #1 on Hacker News is a whole different level. The traffic was insane. I was getting thousands of visitors every hour.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff856y2p2aq1f1jhk9ljh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff856y2p2aq1f1jhk9ljh.png" width="800" height="218"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Reddit
&lt;/h3&gt;

&lt;p&gt;In the same time, I was also posting about it on Reddit. I've posted it on a few subreddits, but the most successful one was on &lt;a href="https://www.reddit.com/r/selfhosted/comments/1b9kfwn/a_collection_of_selfhostable_opensource_software/" rel="noopener noreferrer"&gt;r/SelfHosted&lt;/a&gt;. It quicky got over &lt;strong&gt;250 upvotes&lt;/strong&gt; and a lot of comments.&lt;/p&gt;

&lt;p&gt;Unfortunately, I made a mistake by trying to monetize some of the traffic by adding a Stripe Pay link to the site. For $97, a project could be featured on the top of the list. I thought it would be a good way to monetize the site, but it was a mistake. I got a lot of negative feedback and I quickly removed it.&lt;/p&gt;

&lt;p&gt;Due to that, reddit users quickly turned on me and the site. I was getting a lot of negative comments and downvotes, and the post was &lt;strong&gt;removed from the subreddit&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Still, the traffic was great and I was able to get a lot of feedback from the community.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The site was a huge success. It got over &lt;strong&gt;100,000 visitors in the first week&lt;/strong&gt;, and it's still getting a few thousand visitors every day. It was a great experiment in launching a side project and growth, and I learned a lot from it.&lt;/p&gt;

&lt;p&gt;Hopefully, you'll be able to take something away from this story and apply it to your own projects. If you have any questions or feedback, feel free to reach out to me on &lt;a href="https://twitter.com/piotrkulpinski" rel="noopener noreferrer"&gt;Twitter&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you want to check out the site, you can find it at &lt;a href="https://openalternative.co" rel="noopener noreferrer"&gt;openalternative.co&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>learning</category>
      <category>writing</category>
      <category>startup</category>
    </item>
  </channel>
</rss>
