Documentation

Everything you need to build with Shipr.

Getting Started

Stack

Layer Tech
Framework Next.js 16 (App Router)
Auth Clerk
Database Convex
Styling Tailwind CSS 4 + shadcn/ui + Base UI
Analytics PostHog (reverse-proxied)
Error Tracking Sentry
Fonts Geist Sans / Mono / Pixel Square
Deployment Vercel

Prerequisites

  • Node.js 18+
  • pnpm
  • A Clerk account
  • A Convex project
  • (Optional) PostHog & Sentry accounts

Setup

git clone <repo-url> && cd shipr
pnpm install
cp .env.example .env

Fill in your .env values, then:

npx convex dev   # start Convex dev server
pnpm dev         # start Next.js dev server

Open http://localhost:3000.

Project Structure

src/
├── app/
│   ├── (auth)/          # Sign-in & sign-up pages (Clerk)
│   ├── (dashboard)/     # Protected dashboard
│   ├── (legal)/         # Privacy, terms, cookies
│   ├── (marketing)/     # Landing, features, pricing, about, docs, blog
│   │   └── blog/        # Blog index + [slug] detail pages
│   ├── api/
│   │   ├── email/       # Send transactional emails via Resend
│   │   └── health/      # Health check endpoint (rate-limited)
│   ├── onboarding/      # Multi-step onboarding flow for new users
│   ├── layout.tsx       # Root layout (providers, metadata, fonts)
│   ├── not-found.tsx    # Custom 404 page
│   ├── error.tsx        # App-level error boundary
│   ├── global-error.tsx # Root error boundary
│   ├── robots.ts        # Robots.txt generation
│   └── sitemap.ts       # Sitemap generation
├── components/
│   ├── ui/              # shadcn/ui primitives
│   ├── billing/         # Upgrade button, plan gating
│   ├── dashboard/       # Dashboard shell, sidebar, top nav
│   ├── posthog-*.tsx    # PostHog provider, pageview, identify
│   ├── theme-toggle.tsx # Light/dark/system theme switcher
│   ├── header.tsx       # Marketing header
│   └── footer-1.tsx     # Marketing footer (includes theme toggle)
├── hooks/
│   ├── use-mobile.ts      # Responsive breakpoint hook
│   ├── use-onboarding.ts  # Check onboarding status & redirect logic
│   ├── use-sync-user.ts   # Syncs Clerk user to Convex
│   └── use-user-plan.ts   # Reads current billing plan
├── lib/
│   ├── blog.ts          # Blog post data & helpers
│   ├── constants.ts     # SEO, routes, structured data config
│   ├── emails/          # Email templates + Resend send helper
│   ├── rate-limit.ts    # In-memory sliding window rate limiter
│   ├── structured-data.tsx  # JSON-LD components
│   ├── sentry.ts        # Sentry helper wrappers
│   ├── convex-client-provider.tsx # Convex + Clerk provider
│   └── utils.ts         # cn() class merge utility
convex/
├── schema.ts            # Database schema
├── users.ts             # User queries & mutations
└── auth.config.ts       # Clerk JWT config for Convex

Environment Variables

See .env.example for the full list. Key ones:

Variable Purpose
NEXT_PUBLIC_SITE_URL Canonical site URL
NEXT_PUBLIC_CLERK_* Clerk auth config
NEXT_PUBLIC_CONVEX_URL Convex deployment URL
CLERK_JWT_ISSUER_DOMAIN Clerk JWT issuer (Convex)
RESEND_API_KEY Resend API key for transactional emails
RESEND_FROM_EMAIL Sender address (optional, defaults to onboarding@resend.dev)
NEXT_PUBLIC_POSTHOG_KEY PostHog project API key
NEXT_PUBLIC_POSTHOG_HOST PostHog ingest host
SENTRY_ORG / SENTRY_PROJECT Sentry source map uploads

Scripts

pnpm dev        # Start dev server
pnpm build      # Production build
pnpm start      # Start production server
pnpm lint       # ESLint
npx convex dev  # Convex dev mode

Blog

Posts live in src/lib/blog.ts as a simple array, no MDX or CMS needed. Add a new object to BLOG_POSTS and it appears on /blog with its own /blog/[slug] page, sitemap entry, and JSON-LD structured data automatically.

API Routes

  • GET /api/health - returns uptime and a timestamp, rate-limited to 30 req/min per IP.
  • POST /api/email - sends a transactional email to the authenticated user via Resend. Protected by Clerk auth and rate-limited to 10 req/min per IP. See the Email (Resend) section below.

Rate Limiting

src/lib/rate-limit.ts provides a sliding window rate limiter. Use it in any API route:

import { rateLimit } from "@/lib/rate-limit";

const limiter = rateLimit({ interval: 60_000, limit: 10 });

export async function GET(req: Request) {
  const ip = req.headers.get("x-forwarded-for") ?? "unknown";
  const { success, remaining, reset } = limiter.check(ip);

  if (!success) {
    return Response.json({ error: "Too many requests" }, { status: 429 });
  }

  return Response.json({ ok: true });
}

Note: This is in-memory and resets on cold starts. For production multi-instance deployments, swap it with Upstash Redis or similar.

Email (Resend)

Transactional emails are sent via Resend. Templates and the send helper live in src/lib/emails/.

Setup

  1. Create an account at resend.com and grab your API key.
  2. Add the key to .env:
    RESEND_API_KEY=re_...
    RESEND_FROM_EMAIL=hello@yourdomain.com
    
  3. If you are testing locally without a verified domain, leave RESEND_FROM_EMAIL unset and Resend will use its sandbox sender (onboarding@resend.dev).

Templates

Each template exports a function returning { subject, html }:

  • welcomeEmail({ name }) - welcome email for new sign-ups
  • planChangedEmail({ name, previousPlan, newPlan }) - plan upgrade/downgrade notification

Sending emails server-side

Use the sendEmail helper in any server context (API routes, server actions):

import { sendEmail, welcomeEmail } from "@/lib/emails";

const { subject, html } = welcomeEmail({ name: "Ege" });
const result = await sendEmail({ to: "ege@example.com", subject, html });

if (!result.success) {
  console.error("Email failed:", result.error);
}

API route

POST /api/email sends a template email to the currently authenticated user. It reads the user's email from Clerk, so the caller only provides the template and its data.

{ "template": "welcome", "name": "Ege" }
{
  "template": "plan-changed",
  "name": "Ege",
  "previousPlan": "free",
  "newPlan": "pro"
}

The route is Clerk-authenticated and rate-limited to 10 requests per minute.

Onboarding

New users are automatically redirected to /onboarding on their first dashboard visit. The onboarding flow is a clean, multi-step process that:

  • Welcomes users and shows what to expect
  • Reviews their profile information from Clerk
  • Shows next steps and tips
  • Tracks completion state in Convex

How it works

  1. Tracking: The onboardingCompleted and onboardingStep fields in the Convex users table track progress.
  2. Hook: The useOnboarding hook checks status and redirects appropriately.
  3. Auto-redirect: The DashboardShell component calls useOnboarding() to enforce the flow.
  4. Skip option: Users can skip onboarding and come back later.

Customizing steps

Edit src/app/(dashboard)/onboarding/page.tsx to add your own steps. The current steps are:

  1. Welcome - Introduction and overview
  2. Profile - Show user info from Clerk
  3. Preferences - Final step before completion

Add new steps by:

  1. Adding the step to the STEPS array
  2. Adding a case to the renderStep() function
  3. Updating the OnboardingStep type in convex/users.ts

Reset onboarding

For testing, you can reset a user's onboarding via the Convex dashboard or by calling the resetOnboarding mutation.

Architecture

Tech Stack

Layer Tool
Framework Next.js 16 (App Router)
Auth Clerk
Database Convex
Styling Tailwind CSS 4
Analytics PostHog + Vercel Analytics
Error Tracking Sentry
Payments Clerk Billing
Email Resend

Route Groups

src/app/
├── (auth)/          # Sign-in, sign-up pages (Clerk)
├── (dashboard)/     # Protected pages (requires auth)
├── (legal)/         # Privacy, terms, cookies
├── (marketing)/     # Landing, features, pricing, about, docs, blog
│   └── blog/        # Blog index + [slug] detail pages
├── api/
│   ├── email/       # Send transactional emails via Resend
│   └── health/      # Health check endpoint (rate-limited)
├── layout.tsx       # Root layout - providers, fonts, metadata
├── not-found.tsx    # Custom 404 page
├── error.tsx        # App-level error boundary
└── global-error.tsx # Root error boundary (catches layout errors)

(marketing)

Public pages with HeroHeader and Footer. No auth required. Includes the blog at /blog with individual post pages at /blog/[slug].

(auth)

Clerk's sign-in/sign-up catch-all routes. Centered layout.

(dashboard)

Protected area. Uses sidebar layout with useSyncUser to keep Convex in sync with Clerk.

(legal)

Static legal pages sharing a minimal layout.

Provider Stack

Providers wrap the app in this order (see layout.tsx):

ThemeProvider          - next-themes (light/dark/system)
  PostHogProvider      - Analytics client
    ClerkProvider      - Auth (adapts to theme)
      PostHogIdentify  - Links Clerk user to PostHog
      PostHogPageview  - Tracks route changes
      TooltipProvider  - UI tooltips
        ConvexProvider - Realtime database (uses Clerk auth)

Data Flow

User Sync

Clerk (auth source of truth)
  > useSyncUser hook (client-side)
    > Convex createOrUpdateUser mutation
      > Convex users table

The useSyncUser hook runs on auth'd pages. It compares the Clerk user with the Convex record and only writes when data has changed (email, name, avatar, plan).

Plan Detection

Clerk Billing (has plan: "pro")
  > useUserPlan hook
    > returns { plan, isPro, isFree, isLoading }

No separate billing table - plan is derived from Clerk's has() check and synced to Convex for server-side access.

API Routes

  • POST /api/email - sends a transactional email to the authenticated user via Resend. Protected by Clerk auth and rate-limited to 10 req/min per IP. Accepts a JSON body with a template field ("welcome" or "plan-changed") and the template's required data.
  • GET /api/health - returns { status, timestamp, uptime }. Rate-limited to 30 req/min per IP via the in-memory sliding window limiter in src/lib/rate-limit.ts.

Blog

Posts are defined as a simple array in src/lib/blog.ts. No MDX or CMS - just add an object to BLOG_POSTS and the blog index + detail page + sitemap + JSON-LD are generated automatically.

Email (Resend)

Transactional emails are sent via Resend. Everything lives in src/lib/emails/:

  • send.ts - sendEmail() helper that wraps the Resend SDK (lazily initialized)
  • welcome.ts - welcomeEmail({ name }) returns { subject, html }
  • plan-changed.ts - planChangedEmail({ name, previousPlan, newPlan }) returns { subject, html }
  • index.ts - barrel exports for all templates and the send helper

Use sendEmail() in any server context (API routes, server actions):

import { sendEmail, welcomeEmail } from "@/lib/emails";

const { subject, html } = welcomeEmail({ name: "Ege" });
await sendEmail({ to: "ege@example.com", subject, html });

Requires RESEND_API_KEY in .env. Optionally set RESEND_FROM_EMAIL to override the default sender address.

Rate Limiting

src/lib/rate-limit.ts provides a sliding window rate limiter for API routes. In-memory - suitable for single-instance or low-traffic Vercel serverless. Swap with Upstash Redis for multi-instance production.

Key Files

File Purpose
src/lib/constants.ts SEO config, routes, structured data
src/lib/structured-data.tsx JSON-LD components for SEO
src/lib/blog.ts Blog post data & helpers
src/lib/rate-limit.ts In-memory rate limiter
src/lib/emails/send.ts Resend sendEmail helper
src/lib/emails/ Email templates (welcome, plan)
src/app/api/email/route.ts Send email API route
src/lib/sentry.ts Error tracking helpers
src/lib/convex-client-provider.tsx Convex + Clerk integration
src/hooks/use-sync-user.ts Clerk to Convex user sync
src/hooks/use-user-plan.ts Plan gating hook
src/hooks/use-mobile.ts Responsive breakpoint detection
convex/schema.ts Database schema
convex/users.ts User CRUD mutations/queries

Authentication

Shipr uses Clerk for authentication and Convex for the backend database.

How It Works

  1. Clerk handles sign-in, sign-up, session management, and billing plans
  2. Convex stores user data synced from Clerk via the useSyncUser hook
  3. Users are identified by their Clerk ID across both systems

Architecture

User signs in via Clerk
        |
ClerkProviderWrapper (adapts to dark/light theme)
        |
ConvexProviderWithClerk (passes Clerk auth to Convex)
        |
useSyncUser hook (syncs Clerk user data to Convex DB)

Providers

Providers are nested in the root layout (src/app/layout.tsx):

<ThemeProvider>
  <PostHogProvider>
    <ClerkProviderWrapper>
      <ConvexClientProvider>{children}</ConvexClientProvider>
    </ClerkProviderWrapper>
  </PostHogProvider>
</ThemeProvider>

ClerkProviderWrapper

src/components/clerk-provider-wrapper.tsx

Wraps ClerkProvider and automatically applies the dark theme based on next-themes.

ConvexClientProvider

src/lib/convex-client-provider.tsx

Creates a singleton ConvexReactClient and bridges Clerk auth into Convex via ConvexProviderWithClerk.

User Sync

The useSyncUser hook (src/hooks/use-sync-user.ts) runs on authenticated pages and:

  • Reads the current Clerk user + billing plan
  • Checks if the Convex user record exists and is up-to-date
  • Creates or patches the Convex record only when data has changed

Convex Schema

Users table (convex/schema.ts):

Field Type Description
clerkId string Clerk user ID (indexed)
email string Primary email
name string? Full name
imageUrl string? Profile image URL
plan string? "free" or "pro"

Environment Variables

Variable Description
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY Clerk publishable key
CLERK_SECRET_KEY Clerk secret key
NEXT_PUBLIC_CONVEX_URL Convex deployment URL
CLERK_JWT_ISSUER_DOMAIN Clerk JWT issuer for Convex

Route Groups

  • (auth) - Sign-in and sign-up pages using Clerk's prebuilt components
  • (dashboard) - Protected pages that require authentication
  • (marketing) - Public pages (no auth required)

Security

All Convex mutations and queries enforce ownership checks - users can only read, update, or delete their own records. The identity.subject from Clerk JWT is compared against the clerkId argument on every operation.

SEO

How Shipr handles search engine optimization out of the box.

Metadata

Global metadata is configured in src/app/layout.tsx using Next.js App Router's Metadata API. All values pull from src/lib/constants.ts so you only need to update one file.

Key constants:

  • SITE_CONFIG - site name, URL, description, socials
  • METADATA_DEFAULTS - title template (%s | Shipr), default title
  • OG_IMAGE_DEFAULTS - Open Graph image dimensions and alt text
  • PAGE_SEO - per-page title, description, and keywords

Per-page metadata

Each route exports its own metadata object. Example from src/app/(marketing)/page.tsx:

export const metadata: Metadata = {
  title: PAGE_SEO.home.title,
  description: PAGE_SEO.home.description,
  keywords: [...PAGE_SEO.home.keywords],
  alternates: { canonical: SITE_CONFIG.url },
};

To add a new page, add an entry to PAGE_SEO in constants.ts and export metadata in your page file.

Sitemap

src/app/sitemap.ts generates /sitemap.xml at build time. Routes come from SITEMAP_ROUTES in constants. Each entry has a path, priority, and change frequency.

Add new public routes to SITEMAP_ROUTES - they'll appear in the sitemap automatically.

Robots

src/app/robots.ts generates /robots.txt. Protected and internal routes are blocked via ROBOTS_DISALLOWED in constants.

Structured Data (JSON-LD)

src/lib/structured-data.tsx provides server-safe JSON-LD components:

Component Schema Type Where to use
OrganizationJsonLd Organization Root layout <head>
WebSiteJsonLd WebSite Root layout <head>
SoftwareApplicationJsonLd SoftwareApplication Home / pricing pages
ProductJsonLd Product Pricing page
FaqJsonLd FAQPage Any page with FAQs
BreadcrumbJsonLd BreadcrumbList Nested pages
ArticleJsonLd Article Blog posts

Adding a new schema

  1. Define the data shape as a TypeScript interface.
  2. Create a component that renders <JsonLd data={...} />.
  3. Drop it into the relevant page or layout.

Open Graph & Twitter Images

Static image files live in src/app/:

  • opengraph-image.png (1200×630)
  • twitter-image.png (1200×630)
  • apple-touch-icon.png
  • favicon.ico / icon.svg

Next.js automatically serves these at the correct routes.

Analytics

Shipr uses PostHog for product analytics, routed through a Next.js reverse proxy to avoid ad-blockers.

Setup

Three components handle analytics in the root layout (src/app/layout.tsx):

Component File Purpose
PostHogProvider src/components/posthog-provider.tsx Initializes the PostHog client and wraps the app
PostHogPageview src/components/posthog-pageview.tsx Captures $pageview on route changes
PostHogIdentify src/components/posthog-identify.tsx Links Clerk user identity to PostHog person

Environment Variables

NEXT_PUBLIC_POSTHOG_KEY=phc_...
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

Reverse Proxy

PostHog requests are proxied through Next.js rewrites in next.config.ts to bypass ad-blockers:

  • /ingest/static/* to https://us-assets.i.posthog.com/static/*
  • /ingest/* to https://us.i.posthog.com/*

Tracked Events

Event Location Description
$pageview posthog-pageview.tsx Every route change
cta_clicked Hero, CTA sections User clicked a call-to-action button
pricing_plan_clicked Pricing section User selected a pricing plan
upgrade_button_clicked Dashboard User clicked upgrade
faq_expanded FAQ section User opened a FAQ item
navigation_clicked Header Nav link clicked (includes device type)
mobile_menu_toggled Header Mobile menu opened/closed
theme_toggled Theme toggle Theme changed (includes previous/new theme)

User Identification

PostHogIdentify runs on auth state changes. When a user signs in, it calls posthog.identify() with:

  • email, name, first_name, last_name
  • username, created_at, plan

On sign-out, it calls posthog.reset() to clear the person profile.

Adding New Events

Capture events anywhere with:

import posthog from "posthog-js";

posthog.capture("event_name", { key: "value" });

Keep event names in snake_case. Include relevant context as properties.

Deployment

Environment Variables

Copy .env.example to .env and fill in the values:

cp .env.example .env
Variable Description Required
NEXT_PUBLIC_SITE_URL Your production URL (e.g. https://shipr.dev) Yes
NEXT_PUBLIC_CONVEX_URL Convex deployment URL Yes
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY Clerk publishable key Yes
CLERK_SECRET_KEY Clerk secret key Yes
CLERK_JWT_ISSUER_DOMAIN Clerk JWT issuer for Convex auth Yes
RESEND_API_KEY Resend API key for transactional emails Yes
RESEND_FROM_EMAIL Sender address (defaults to onboarding@resend.dev) Optional
NEXT_PUBLIC_POSTHOG_KEY PostHog project API key Optional
NEXT_PUBLIC_POSTHOG_HOST PostHog ingest host Optional
SENTRY_AUTH_TOKEN Sentry auth token for source maps Optional

Vercel

  1. Push your repo to GitHub
  2. Import the project in Vercel
  3. Add all environment variables in the Vercel dashboard
  4. Deploy - Vercel auto-detects Next.js

Build settings

Vercel should auto-detect these, but if not:

  • Framework: Next.js
  • Build command: pnpm build
  • Output directory: .next

Convex

  1. Install the CLI: pnpm add -g convex
  2. Run npx convex dev locally to sync your schema
  3. For production, deploy with npx convex deploy
  4. Set CLERK_JWT_ISSUER_DOMAIN in the Convex dashboard under Authentication

Clerk

  1. Create a project at clerk.com
  2. Copy your keys to .env
  3. Enable Clerk Billing if you want Pro/Free plan gating
  4. Configure the JWT issuer domain for Convex integration

Sentry

  1. Create a project at sentry.io
  2. Add SENTRY_AUTH_TOKEN to your environment
  3. Source maps are uploaded automatically during build via @sentry/nextjs
  4. Error tracking works out of the box - see src/lib/sentry.ts for helpers

Resend

  1. Create an account at resend.com
  2. Add a verified domain (or use the sandbox sender for testing)
  3. Copy your API key and add RESEND_API_KEY to .env
  4. Optionally set RESEND_FROM_EMAIL to your verified sender address (e.g. hello@yourdomain.com)
  5. The POST /api/email route and the sendEmail helper in src/lib/emails/send.ts will use these values at runtime

PostHog

  1. Create a project at posthog.com
  2. Add NEXT_PUBLIC_POSTHOG_KEY and NEXT_PUBLIC_POSTHOG_HOST to .env
  3. The app uses a reverse proxy via Next.js rewrites to bypass ad blockers (configured in next.config.ts)