forklifter

Portfolio generator with analytics & dynamic OG images

Jan 1, 2024

Forklifter is a portfolio generator. Sign in with GitHub or Google, fill in your projects, experience, testimonials, and open-source contributions, and get a portfolio page at /:username with analytics and dynamic OG images. The dashboard is interactive (client components, shadcn/ui), but the generated portfolios are fully server-rendered — zero client JS for the people actually visiting your portfolio.

Server Components for portfolios, client components for the dashboard

The split is deliberate. Portfolio pages at /:username are async Server Components — one Prisma query fetches the user with all their nested data, and the page renders to HTML on the server. No hydration, no client bundle, no loading spinners. Fast for visitors, good for SEO.

The dashboard is the opposite: forms, drag-and-drop reordering, dialogs, toasts. All client components with shadcn/ui (Radix primitives) and Framer Motion for the portfolio section animations. The two sides share the same Prisma models but have completely different rendering strategies.

export default async function Website({ params }) {
  const user = await getFullUserDetails(params.username);
  if (!user) redirect(notFound());

  return (
    <>
      <Header ... />
      <section className="space-y-36">
        <Hero mail={user.email} name={user.name} oneLiner={user.oneLiner} />

        {/* Conditional sections — empty data = no section */}
        {user.bio && user.twitterUrl && user.githubUrl && user.linkedinUrl && (
          <AboutMe bio={user.bio} techStack={user.techStack} ... />
        )}

        {user.projects?.length > 0 && (
          <FeaturedProjects projects={user.projects} />
        )}

        {user.experiences?.length > 0 && (
          <Experiences experiences={user.experiences} />
        )}

        {user.contributions?.length > 0 && (
          <Contributions contributions={user.contributions} />
        )}

        {user.testimonials?.length > 0 && (
          <Testimonials testimonials={user.testimonials} />
        )}
      </section>
    </>
  );
}

Each section checks for data before rendering. No bio or missing social links? The About section doesn't appear. No projects? No projects section. This avoids half-empty sections that make a portfolio look unfinished.

Server Actions for all mutations

Every mutation — profile updates, project CRUD, experience CRUD, testimonials — is a Server Action. No API routes for data mutations. Each action authenticates, validates, writes to Prisma, and calls revalidatePath() on both the dashboard route and the user's portfolio route. This bi-directional revalidation ensures that editing your profile in the dashboard immediately reflects on your public portfolio.

'use server';
import 'server-only';

export async function updateProfile({
  bio, displayName, linkedinUrl, oneLiner,
  email, githubUrl, twitterUrl, username, techStack,
}) {
  const user = await getCurrentUser();
  if (!user) throw new Error("You're not authenticated.");

  const dbUser = await prisma.user.findUnique({ where: { id: user.id } });

  // Reserved route check
  if (dbUser.username !== username) {
    if (
      username === 'dashboard' ||
      username.includes('dashboard/') ||
      username.includes('sign-in') ||
      username.includes('signout')
    ) {
      throw new Error('This is not a valid username');
    }

    const usernameExists = await prisma.user.findUnique({
      where: { username },
    });
    if (usernameExists) {
      throw new Error(`Username ${username} already exists`);
    }
  }

  await prisma.user.update({
    data: { name: displayName, username, email, oneLiner, ... },
    where: { id: user.id },
  });

  // Bi-directional revalidation
  revalidatePath(`/dashboard`);
  revalidatePath(`/${dbUser.username}`);
}

The username validation in the profile update does double duty: it checks for reserved routes (dashboard, sign-in, signout) at the application level, and the Zod schema enforces the format — alphanumeric plus underscores, 1-25 characters.

username: z
  .string()
  .regex(/^[a-zA-Z0-9_]+$/, {
    message: 'Username must only contain alphanumeric characters and underscores.',
  })
  .min(1)
  .max(25)
  .refine((val) => val !== 'dashboard', {
    message: 'This is not a valid username',
  }),

export async function addProject(input: z.infer<typeof projectSchema>) {
  const user = await getCurrentUser();
  if (!user) throw new Error("You're not authenticated.");

  const dbUser = await prisma.user.findUnique({ where: { id: user.id } });

  await prisma.project.create({
    data: { ...input, userId: user.id },
  });

  revalidatePath(`/dashboard/projects`);
  revalidatePath(`/${dbUser.username}`);
}

export async function deleteProject(id: string) {
  const user = await getCurrentUser();
  if (!user) throw new Error("You're not authenticated.");

  await prisma.project.delete({
    where: { id, userId: user.id },
  });

  revalidatePath(`/dashboard/projects`);
}

MongoDB over Postgres

Portfolio data is document-shaped — a user with nested projects, experiences, testimonials, and contributions. No joins needed. A single findUnique with include fetches everything for the portfolio page. MongoDB's flexible schema also made it easy to iterate on the data model during early development without running migrations for every field change.

model User {
  id            String         @id @default(auto()) @map("_id") @db.ObjectId
  username      String         @unique @default(cuid())
  email         String         @unique
  name          String?
  oneLiner      String?
  bio           String?
  techStack     Stack[]        @default([])   // 48-value enum
  projects      Project[]
  experiences   Experience[]
  testimonials  Testimonial[]
  contributions Contribution[]
}

// 48 values: Angular, AWS, Bun, CSS, Deno, Docker, ... WebRTC
enum Stack { Angular AWS Bun CSS /* ... 44 more */ WebRTC }

model Project {
  id          String   @id @default(auto()) @map("_id") @db.ObjectId
  name        String
  description String
  techStack   String[]   // freeform strings, not the enum
  webUrl      String
  githubUrl   String
  userId      String     @db.ObjectId
  user        User       @relation(fields: [userId], references: [id], onDelete: Cascade)
}

Tech stack: enum vs. freeform

The user profile uses a 48-value Stack enum (Angular, AWS, Docker, React, etc.) for the "I know these technologies" section. This gives consistency and lets me map each value to an icon. But project tech stacks use freeform String[] — projects use specific libraries, versions, and tools that don't fit a fixed enum. It's a pragmatic split: constrained where consistency matters, flexible where it doesn't.

Dynamic OG images at the edge

Each portfolio gets a dynamic social card generated with @vercel/og (Satori under the hood). The Edge Runtime route renders a 1200×630 PNG with the user's initials, name, and one-liner. Custom fonts are loaded as ArrayBuffers from local TTF files — Satori can't load fonts from URLs.

import { ImageResponse } from 'next/og';

export const runtime = 'edge';

export async function GET(request: NextRequest) {
  const username = request.nextUrl.searchParams.get('username');

  // Load custom font as ArrayBuffer (Satori requirement)
  const interBold = await fetch(
    new URL('path/to/Inter-Bold.ttf', import.meta.url),
  ).then((res) => res.arrayBuffer());

  const user = await prisma.user.findUnique({
    where: { username: username ?? '' },
  });

  // Fallback — doesn't break social card display
  if (!user)
    return new ImageResponse(
      <div tw="flex h-full w-full items-center justify-center bg-black">
        <h1 tw="text-4xl text-white">User not found</h1>
      </div>,
      { width: 1200, height: 630 },
    );

  return new ImageResponse(
    <div tw="flex h-full w-full flex-col items-center justify-center bg-black">
      <h1 tw="text-8xl font-bold text-white">{getInitials(user.name)}</h1>
      <h1 tw="text-4xl text-white">{user.name}</h1>
      <p tw="text-xl text-zinc-400">{user.oneLiner}</p>
    </div>,
    {
      width: 1200,
      height: 630,
      fonts: [{ name: 'Inter', data: interBold, style: 'normal' }],
    },
  );
}

The main limitation is Satori's CSS support. No CSS Grid, limited Flexbox, no gap in some contexts. The tw prop provides Tailwind-like syntax, but you're working within a subset. The fallback for missing users returns an error text image instead of failing — social card crawlers get a valid image either way.

Three-layer analytics

Analytics come from three sources: LogLib for custom metrics (unique visitors, bounce rate, top pages, referrers, locations), Vercel Analytics for Web Vitals, and a custom Server Action that queries the LogLib API filtered to the user's /:username path. This gives each user analytics for their specific portfolio page without exposing data from other users.

'use server';

export async function fetchAnalytics() {
  const user = await getCurrentUser();

  const { data: insights } = await axios.get<{
    insight: {
      uniqueVisitors: { current: number; change: number };
      totalPageViews: { current: number; change: number };
      averageTime: { current: string; change: number };
      bounceRate: { current: number; change: number };
    };
    data: {
      pages: { page: string; visits: number }[];
      devices: { device: string; visits: number }[];
      referrer: { referrer: string; visits: number }[];
      locations: { city: string; country: string; visits: number }[];
    };
  }>(`https://api.loglib.io/v1/insight?apiKey=${env.LOGLIB_API_KEY}`);

  // Filter for this user's portfolio page only
  const pageData = insights.data.pages.filter(
    (p) => p.page === `/${user.username}`,
  );
  return pageData;
}

Link previews with OG scraping

The dashboard shows link previews for project URLs and experience organization URLs. A Server Action uses open-graph-scraper (Cheerio-based) to fetch OG metadata from the URL. If the scrape fails or there's no OG image, it falls back to a placeholder. The scraping runs client-side via useEffect — each experience entry fetches its org's OG image independently so slow URLs don't block the rest of the page.

'use server';
import og from 'open-graph-scraper';

export async function getOgImageUrl(url: string): Promise<string> {
  try {
    const { result } = await og({ url });
    if (!result?.ogImage?.some((im) => im.url)) {
      return '/images/social-bg-dark-lines.jpg'; // fallback
    }
    return result.ogImage[0].url;
  } catch (error) {
    console.error(`Error fetching og image for ${url}:`, error);
    return '/images/social-bg-dark-lines.jpg';
  }
}

The details

Username validation blocks reserved routes: dashboard, sign-in, signout can't be usernames. The regex enforces alphanumeric plus underscores, 1-25 characters. Without this, someone registering "dashboard" as a username would shadow the actual dashboard route.

Conditional portfolio sections skip rendering entirely when data is missing. If there's no bio or social links, the About section isn't rendered — not as an empty shell, but not at all. Same for projects, experiences, testimonials, and contributions. This means a new user's portfolio shows just their name and one-liner until they add content.

Cascade deletes in Prisma ensure that deleting a user removes all their projects, experiences, testimonials, and contributions. The onDelete: Cascade on every relation means no orphaned records.

Welcome email via Resend with React Email templates. The email component uses @react-email/components for consistent rendering across email clients — Tailwind classes compiled to inline styles. Triggers on first sign-in through the NextAuth callback.

Auth with NextAuth v4 using PrismaAdapter for session persistence in MongoDB. Google and GitHub OAuth providers. The session callback injects username and id into the session object so Server Actions can identify the user without an extra DB query per request.

forklifter | Yash`s website