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
- Create an account at resend.com and grab your API key.
- Add the key to
.env:RESEND_API_KEY=re_... RESEND_FROM_EMAIL=hello@yourdomain.com - If you are testing locally without a verified domain, leave
RESEND_FROM_EMAILunset 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-upsplanChangedEmail({ 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
- Tracking: The
onboardingCompletedandonboardingStepfields in the Convexuserstable track progress. - Hook: The
useOnboardinghook checks status and redirects appropriately. - Auto-redirect: The
DashboardShellcomponent callsuseOnboarding()to enforce the flow. - 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:
- Welcome - Introduction and overview
- Profile - Show user info from Clerk
- Preferences - Final step before completion
Add new steps by:
- Adding the step to the
STEPSarray - Adding a case to the
renderStep()function - Updating the
OnboardingSteptype inconvex/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 |
| 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 atemplatefield ("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 insrc/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
- Clerk handles sign-in, sign-up, session management, and billing plans
- Convex stores user data synced from Clerk via the
useSyncUserhook - 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, socialsMETADATA_DEFAULTS- title template (%s | Shipr), default titleOG_IMAGE_DEFAULTS- Open Graph image dimensions and alt textPAGE_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
- Define the data shape as a TypeScript interface.
- Create a component that renders
<JsonLd data={...} />. - 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.pngfavicon.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/*tohttps://us-assets.i.posthog.com/static/*/ingest/*tohttps://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_nameusername,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
- Push your repo to GitHub
- Import the project in Vercel
- Add all environment variables in the Vercel dashboard
- 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
- Install the CLI:
pnpm add -g convex - Run
npx convex devlocally to sync your schema - For production, deploy with
npx convex deploy - Set
CLERK_JWT_ISSUER_DOMAINin the Convex dashboard under Authentication
Clerk
- Create a project at clerk.com
- Copy your keys to
.env - Enable Clerk Billing if you want Pro/Free plan gating
- Configure the JWT issuer domain for Convex integration
Sentry
- Create a project at sentry.io
- Add
SENTRY_AUTH_TOKENto your environment - Source maps are uploaded automatically during build via
@sentry/nextjs - Error tracking works out of the box - see
src/lib/sentry.tsfor helpers
Resend
- Create an account at resend.com
- Add a verified domain (or use the sandbox sender for testing)
- Copy your API key and add
RESEND_API_KEYto.env - Optionally set
RESEND_FROM_EMAILto your verified sender address (e.g.hello@yourdomain.com) - The
POST /api/emailroute and thesendEmailhelper insrc/lib/emails/send.tswill use these values at runtime
PostHog
- Create a project at posthog.com
- Add
NEXT_PUBLIC_POSTHOG_KEYandNEXT_PUBLIC_POSTHOG_HOSTto.env - The app uses a reverse proxy via Next.js rewrites to bypass ad blockers (configured in
next.config.ts)