DEV Community

Cover image for Get your own DEV wrapped for 2023 🎁
Jonas Scholz
Jonas Scholz

Posted on

Get your own DEV wrapped for 2023 🎁

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:

my wrapped

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:

  1. Next.js + Tailwind
  2. 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
Enter fullscreen mode Exit fullscreen mode

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:

  1. No scraping - takes too long, I want the data to be available <1 second
  2. Only public data - I do not want to ask the user for an API key or use my own
  3. No database needed - I am lazy and want to avoid useless complexity

This gives us 2 possible ways to get to data:

  1. Documented and unauthenticated API calls
  2. 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:

  1. Public user information using the API: dev.to/api/users/by_username
  2. Published posts using the dev.to/search/feed_content API with class_name=Article
  3. Comments that include a search query with dev.to/search/feed_content and class_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;
}
Enter fullscreen mode Exit fullscreen mode

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,
  );
Enter fullscreen mode Exit fullscreen mode

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>,
  );
Enter fullscreen mode Exit fullscreen mode

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 🫠" />;
  }
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

Sliplane create service

After clicking deploy it takes a few minutes to build and start our Docker image. The progress can be monitored in the log viewer:

Sliplane 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:

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)

Collapse
 
wimadev profile image
Lukas Mauser

Image description

🀠

Collapse
 
wyattdave profile image
david wyatt

Image description

Collapse
 
ben profile image
Ben Halpern

Wow, very well done.

Collapse
 
code42cate profile image
Jonas Scholz

means a lot coming from you, thanks πŸ₯Ί

Collapse
 
ingosteinke profile image
Ingo Steinke

Wow, nice work! and I like it much better than other app's wrapped reviews. Here it is:
Ingo Steinke's DEV.to Wrapped 2023

Collapse
 
kasuken profile image
Emanuele Bartolesi

Image description

Collapse
 
code42cate profile image
Jonas Scholz

79 posts is amazing!

Collapse
 
kasuken profile image
Emanuele Bartolesi

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 :(

Collapse
 
userof profile image
Matthias Wiebe

Nice - So far u have the most Posts Published - In comparison to all the others who were using this tool from Jonas ;)

Collapse
 
pachicodes profile image
Pachi πŸ₯‘

This is awesome and I love it!!!!
Thanks for building this

Image description

Collapse
 
jorensm profile image
JorensM

Great app and concept, good job! Here is mine, it didn't all fit into a single screen, I hope I'm still legible for the stickers 😜

Image description

Collapse
 
code42cate profile image
Jonas Scholz

Oi JorensM! I just did a raffle and your name popped up. I'd love to send you the stickers, please check your gmail:)

Collapse
 
jorensm profile image
JorensM

Oh that's amazing! Thanks!

Collapse
 
code42cate profile image
Jonas Scholz

Thanks, and of course :)

Collapse
 
rutamhere profile image
Rutam Prita Mishra

Great Stuff! Loved creating mine.
Wrapped

Collapse
 
code42cate profile image
Jonas Scholz

a ninja πŸ₯·πŸ‘€

Collapse
 
wraith profile image
Jake Lundberg

Love this 😍 Such a fun idea! Well done!

Image description

Collapse
 
code42cate profile image
Jonas Scholz

Thank you:))

Collapse
 
anitaolsen profile image
Anita Olsen

Fun project, well done! ✨ (I have only been a member for 6 weeks)

My 2023 on DEV in review

Collapse
 
code42cate profile image
Jonas Scholz

6 weeks and 7 posts, thats super impressive. Keep it up:)

Collapse
 
anitaolsen profile image
Anita Olsen

Thank you, I will! πŸ˜ƒ

Collapse
 
andypiper profile image
Andy Piper

I love this! Nice work, and thanks for the write-up as well.

Collapse
 
code42cate profile image
Jonas Scholz

Thank you so much!

Collapse
 
morgannadev profile image
Morganna

Awesome, I love it!

Collapse
 
code42cate profile image
Jonas Scholz

Thanks!:D

Collapse
 
moopet profile image
Ben Sinclair

Wow, apparently my engagement last year was less than half an hour :P

Collapse
 
code42cate profile image
Jonas Scholz

hah, probably not. This is a very rough estimate:D

Collapse
 
dev_kiran profile image
Kiran Naragund

Awesome Jonas✨
Loved it😍

dev_kiran

Collapse
 
code42cate profile image
Jonas Scholz

Thank you:)

Collapse
 
jess profile image
Jess Lee

This is so very cool!

Collapse
 
code42cate profile image
Jonas Scholz

Thank you!!