With everyone and their cat creating a "2023 Wrapped" for their apps, I could not hold back and had to build a small open-source one for this awesome dev.to community π₯°
Visit devto-wrapped.sliplane.app, enter your username and see what you achieved as an author on dev.to in 2023! No API key or login required!
This is how my first year on dev.to went:
PS: Share a screenshot of your wrapped in the comments and I will randomly pick one person and send them some free developer stickers as an early christmas gift π π
Anyway, you are here to learn something, so lets dive into the code!
Tutorial
The speed of building this small app was crucial for me, so I decided to use my own Hackathon Starter Template that I recently wrote about. I stripped some functionality away that I didnt need, resulting in a very lean monorepo with:
- Next.js + Tailwind
- ShadcnUI
You can see everything in this Github repository
Setup
If you want to follow a long and try it out yourself follow these steps:
# Clone repository git clone https://github.com/Code42Cate/devto-wrapped.git # Install dependencies pnpm install # Start app pnpm run dev --filter web
The app should now start at http://localhost:3000. If it didnt work let me know in the comments!
Accessing dev.to data
The most interesting part of this small app is probably how we can access the dev.to data. While there are a few ways to go about this, I had a few requirements that helped me decide a way forward:
- No scraping - takes too long, I want the data to be available <1 second
- Only public data - I do not want to ask the user for an API key or use my own
- No database needed - I am lazy and want to avoid useless complexity
This gives us 2 possible ways to get to data:
- Documented and unauthenticated API calls
- Undocumented and public API calls that the dev.to website is making even if you are not logged in
Considering these two ways of getting data, there are basically 3 categories of data we can get:
- Public user information using the API:
dev.to/api/users/by_username
- Published posts using the
dev.to/search/feed_content
API withclass_name=Article
- Comments that include a search query with
dev.to/search/feed_content
andclass_name=Comment&search_fields=xyz
These API calls are all made server-side to speed up the requests and can be found in /apps/web/actions/api.ts
. Since this is just hacked together, the functions are rather simple with very minimal error handling:
export async function getUserdata(username: string): Promise<User | undefined> { const res = await fetch( `https://dev.to/api/users/by_username?url=${username}`, ); if (!res.ok) { return undefined; } const data = await res.json(); return data as User; }
For this usecase its fine, but remember to correctly catch exceptions and validate your types if you don't want your user to have unexpected crashes π΅
Calulating stats
Calculating the stats was surprisingly easy, mostly just because our data is very small. Even if you post everyday, we would still only have 365 posts to go through. Iterating through an array of 365 items takes virtually no time, giving us a lot of headroom to just get the job done without needing to care about performance! Every stat that you see on the page is calculated in a single function. Take the "Total reactions" for example:
const reactionsCount = posts?.reduce( (acc: number, post: Article) => acc + post.public_reactions_count, 0, );
All we need to do is go over the array of posts and sum up the public_reactions_count
number on each post. Tada, done!
Even for the more complicated ones, its not more than a nested loop:
const postsPerTag: Record<string, number> = posts?.reduce( (acc: Record<string, number>, post: Article) => { post.tag_list.forEach((tag) => { acc[tag] = acc[tag] ? acc[tag] + 1 : 1; }); return acc; }, {} as Record<string, number>, );
Frontend
Since this is build with Next.js, everything can be found in the /apps/web/app/page.tsx
file.
At the top of the component you can first see how we fetch our data and check if the user even exists or if there is enough data to show anything at all:
const user = await getUserdata(username); if (!user) { return <EmptyUser message="This user could not be found π« " />; } const stats = await getStats(user.id.toString()); const mentionsCount = await getMentionedCommentCount(user.username); if (stats.postCount === 0) { return <EmptyUser message="This user has no posts π« " />; }
The different stats are all their own components which are part of a CSS grid, which looks something like this (shortened)
<div className="grid grid-cols-2 gap-2 w-full text-sm text-gray-800"> <PublishedPostsCard count={stats.postCount} /> <ReactionsCard count={stats.reactionsCount} /> <BusiestMonthCard busiestMonth={stats.busiestMonth} postsPerMonth={stats.postsPerMonth} /> <CommentsCard count={stats.commentsCount} /> <ReadingTimeCard readingTime={stats.readingTime} totalEstimatedReadingTime={stats.totalEstimatedReadingTime} /> </div>
The components are all "dumb", meaning that they are only responsible for displaying their data. They do not fetch or calculate anything. Most of them are pretty simple like this "Best Post" card:
import Image from "next/image"; import { Article } from "@/actions/api"; export default function BestPostCard({ post, coverImage, }: { post: Article; coverImage: string; }) { return ( <div className="flex w-full flex-col justify-between gap-2 rounded-xl border border-gray-300 bg-white p-4 shadow-md"> Your fans really loved this post: <br /> <Image src={coverImage} alt={post.title} width={500} height={500} className="rounded-md border border-gray-300" /> <a className="font-semibold underline-offset-2" href={`https://dev.to${post.path}`} > {post.title} </a> </div> ); }
Deployment
To deploy our app we are going to dockerize it and then use Sliplane (slightly biased, I am the co-founder!) to host it on our own Hetzner Cloud server. I covered how dockerizing a Next.js app works in a previous blog post, this is basically the same just with some small changes to adapt to my Turborepo setup :)
# src Dockerfile: https://github.com/vercel/turbo/blob/main/examples/with-docker/apps/web/Dockerfile FROM node:18-alpine AS alpine # setup pnpm on the alpine base FROM alpine as base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable RUN pnpm install turbo --global FROM base AS builder # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. RUN apk add --no-cache libc6-compat RUN apk update # Set working directory WORKDIR /app COPY . . RUN turbo prune --scope=web --docker # Add lockfile and package.json's of isolated subworkspace FROM base AS installer RUN apk add --no-cache libc6-compat RUN apk update WORKDIR /app # First install the dependencies (as they change less often) COPY .gitignore .gitignore COPY --from=builder /app/out/json/ . COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml COPY --from=builder /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml RUN pnpm install # Build the project COPY --from=builder /app/out/full/ . COPY turbo.json turbo.json RUN turbo run build --filter=web # use alpine as the thinest image FROM alpine AS runner WORKDIR /app # Don't run production as root RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs USER nextjs COPY --from=installer /app/apps/web/next.config.js . COPY --from=installer /app/apps/web/package.json . # Automatically leverage output traces to reduce image size # https://nextjs.org/docs/advanced-features/output-file-tracing COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/standalone ./ COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static COPY --from=installer --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public CMD node apps/web/server.js
After dockerizing and pushing to a Github repository, all we need to do is create a new service in Sliplane and select a server that we want to host on. I already have a server that I run some small side projects on, so I am just using that one:
After clicking deploy it takes a few minutes to build and start our Docker image. The progress can be monitored in the log viewer:
After the first successfull deploy we get a free subdomain where our app is reachable, or we can add our own custom domain:
And that's it! Our app is online and reachable by everyone in the world and without surprising serverless bills π€
Thank you for reading until now and don't forget to comment with your wrapped screenshot to potentially win some stickers π
Cheers, Jonas
Top comments (68)
π€
Wow, very well done.
means a lot coming from you, thanks π₯Ί
79 posts is amazing!
It's a kind of habits for me right now. They should be more but I stopped to do my weekly recap a few week ago :(
Nice - So far u have the most Posts Published - In comparison to all the others who were using this tool from Jonas ;)
Wow, nice work! and I like it much better than other app's wrapped reviews. Here it is:

This is so very cool!
Thank you!!
This is awesome and I love it!!!!
Thanks for building this
Great Stuff! Loved creating mine.

a ninja π₯·π
Love this π Such a fun idea! Well done!
Thank you:))
Fun project, well done! β¨ (I have only been a member for 6 weeks)
6 weeks and 7 posts, thats super impressive. Keep it up:)
Thank you, I will! π