Remix takes a web-standards-first approach to building full-stack applications. Instead of client-side state management, Remix uses loaders for data fetching and actions for mutations — both running on the server. This means your SaaS works without JavaScript enabled and progressively enhances when it loads. This guide builds a SaaS application with cookie-based authentication, Prisma for database access, and Stripe for subscription billing.
Prerequisites
- →Node.js 20+
Required for Remix and its Vite-based build system.
- →PostgreSQL Database
A PostgreSQL instance for storing users and subscriptions. Neon or Railway offer free tiers for development.
- →Stripe Account
For processing subscription payments. Test mode is available immediately after registration.
- →React and TypeScript Fundamentals
Remix uses React for UI rendering and TypeScript for type safety across the full stack.
Scaffold the Remix Project
Create a new Remix application using the latest Vite-based template. Remix now uses Vite as its compiler, which gives you fast HMR, better build performance, and access to the Vite plugin ecosystem. Install Prisma for database access and bcryptjs for password hashing.
npx create-remix@latest my-saas --template remix-run/remix/templates/vite
cd my-saas
npm install @prisma/client stripe bcryptjs
npm install -D prisma @types/bcryptjs tailwindcss @tailwindcss/viteTip: Remix with Vite supports hot module replacement for instant feedback during development.
Tip: Add Tailwind CSS as a Vite plugin in vite.config.ts for zero-config styling.
Set Up the Database Schema with Prisma
Initialize Prisma and define your data models for users, organizations, and subscriptions. Remix's loader/action pattern works naturally with Prisma because all database operations happen on the server. Define your schema with relations between users and their subscription state.
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String?
organizationId String?
organization Organization? @relation(fields: [organizationId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Organization {
id String @id @default(cuid())
name String
stripeCustomerId String? @unique
users User[]
subscription Subscription?
createdAt DateTime @default(now())
}
model Subscription {
id String @id @default(cuid())
organizationId String @unique
organization Organization @relation(fields: [organizationId], references: [id])
stripeSubscriptionId String @unique
stripePriceId String
status String
currentPeriodEnd DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}Tip: Run 'npx prisma db push' to sync the schema to your database during development.
Tip: Create a singleton Prisma client in app/lib/db.server.ts — the .server suffix ensures it never leaks to the client bundle.
Build Cookie-Based Authentication
Implement sign-up and login using Remix's built-in cookie session storage. Unlike JWT-based auth, cookie sessions are httpOnly and secure by default, preventing XSS attacks. Remix actions handle form submissions on the server, so authentication works even without JavaScript enabled in the browser.
// app/lib/session.server.ts
import { createCookieSessionStorage, redirect } from "@remix-run/node";
import { prisma } from "./db.server";
import bcrypt from "bcryptjs";
const sessionStorage = createCookieSessionStorage({
cookie: {
name: "__session",
httpOnly: true,
maxAge: 60 * 60 * 24 * 30, // 30 days
path: "/",
sameSite: "lax",
secrets: [process.env.SESSION_SECRET!],
secure: process.env.NODE_ENV === "production",
},
});
export async function createUserSession(userId: string, redirectTo: string) {
const session = await sessionStorage.getSession();
session.set("userId", userId);
return redirect(redirectTo, {
headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
});
}
export async function requireUser(request: Request) {
const session = await sessionStorage.getSession(request.headers.get("Cookie"));
const userId = session.get("userId");
if (!userId) throw redirect("/login");
const user = await prisma.user.findUnique({
where: { id: userId },
include: { organization: { include: { subscription: true } } },
});
if (!user) throw redirect("/login");
return user;
}
export async function login(email: string, password: string) {
const user = await prisma.user.findUnique({ where: { email } });
if (!user) return null;
const isValid = await bcrypt.compare(password, user.passwordHash);
return isValid ? user : null;
}Tip: The .server.ts suffix guarantees this file is never included in client bundles — Remix enforces this at build time.
Tip: Use bcrypt with a salt factor of 10-12 for password hashing — it is intentionally slow to resist brute force attacks.
Create the Login and Signup Routes
Build login and signup pages using Remix actions for form handling. Remix actions receive the form data, validate it, and either return errors or redirect on success. Because this uses standard HTML forms, the authentication flow works without any client-side JavaScript — progressive enhancement at its best.
// app/routes/login.tsx
import type { ActionFunctionArgs, MetaFunction } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import { login, createUserSession } from "~/lib/session.server";
export const meta: MetaFunction = () => [
{ title: "Login | My SaaS" },
];
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const email = String(formData.get("email"));
const password = String(formData.get("password"));
if (!email || !password) {
return { error: "Email and password are required." };
}
const user = await login(email, password);
if (!user) {
return { error: "Invalid email or password." };
}
return createUserSession(user.id, "/dashboard");
}
export default function LoginPage() {
const actionData = useActionData<typeof action>();
return (
<main className="flex min-h-screen items-center justify-center">
<Form method="post" className="w-full max-w-sm space-y-4">
<h1 className="text-2xl font-bold">Login</h1>
{actionData?.error && (
<p className="text-sm text-red-600">{actionData.error}</p>
)}
<input
type="email"
name="email"
placeholder="Email"
required
className="w-full rounded border px-3 py-2"
/>
<input
type="password"
name="password"
placeholder="Password"
required
className="w-full rounded border px-3 py-2"
/>
<button
type="submit"
className="w-full rounded bg-black py-2 text-white hover:bg-gray-800"
>
Sign in
</button>
<p className="text-center text-sm text-gray-600">
Don't have an account? <a href="/signup" className="text-blue-600">Sign up</a>
</p>
</Form>
</main>
);
}Build the Dashboard with Loaders
Create a protected dashboard route using Remix loaders. The loader runs on every request, checks authentication, and fetches the user's data and subscription status. If the user is not authenticated, the loader redirects to login. Nested routes let you build a dashboard layout with sidebar navigation shared across all dashboard pages.
// app/routes/dashboard.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { Outlet, useLoaderData, NavLink } from "@remix-run/react";
import { requireUser } from "~/lib/session.server";
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireUser(request);
return {
user: {
name: user.name,
email: user.email,
plan: user.organization?.subscription?.status ?? "free",
},
};
}
export default function DashboardLayout() {
const { user } = useLoaderData<typeof loader>();
return (
<div className="flex min-h-screen">
<aside className="w-64 border-r bg-gray-50 p-6">
<p className="mb-1 font-semibold">{user.name}</p>
<p className="mb-6 text-sm text-gray-500">{user.plan} plan</p>
<nav className="space-y-2">
<NavLink to="/dashboard" end className={({ isActive }) =>
`block rounded px-3 py-2 ${isActive ? "bg-black text-white" : "hover:bg-gray-200"}`
}>
Overview
</NavLink>
<NavLink to="/dashboard/settings" className={({ isActive }) =>
`block rounded px-3 py-2 ${isActive ? "bg-black text-white" : "hover:bg-gray-200"}`
}>
Settings
</NavLink>
<NavLink to="/dashboard/billing" className={({ isActive }) =>
`block rounded px-3 py-2 ${isActive ? "bg-black text-white" : "hover:bg-gray-200"}`
}>
Billing
</NavLink>
</nav>
</aside>
<main className="flex-1 p-8">
<Outlet />
</main>
</div>
);
}Tip: Remix nested routes automatically share the parent layout — child routes render inside the <Outlet /> component.
Tip: Loaders run in parallel for nested routes, so parent and child data loading happens concurrently.
Integrate Stripe Billing with Actions
Create a billing route that handles Stripe Checkout session creation using a Remix action. When users click 'Upgrade', the form submits to the action, which creates a Stripe Checkout session and redirects them to Stripe's hosted payment page. This pattern works without JavaScript because it uses standard HTTP redirects.
// app/routes/dashboard.billing.tsx
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import { redirect } from "@remix-run/node";
import { requireUser } from "~/lib/session.server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireUser(request);
return {
plan: user.organization?.subscription?.status ?? "free",
currentPeriodEnd: user.organization?.subscription?.currentPeriodEnd,
};
}
export async function action({ request }: ActionFunctionArgs) {
const user = await requireUser(request);
const formData = await request.formData();
const priceId = String(formData.get("priceId"));
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
customer_email: user.email,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.APP_URL}/dashboard/billing?success=true`,
cancel_url: `${process.env.APP_URL}/dashboard/billing`,
metadata: { userId: user.id },
});
return redirect(session.url!);
}
export default function BillingPage() {
const { plan, currentPeriodEnd } = useLoaderData<typeof loader>();
return (
<div>
<h2 className="text-2xl font-bold">Billing</h2>
<p className="mt-2 text-gray-600">
Current plan: <strong>{plan}</strong>
</p>
{plan === "free" ? (
<div className="mt-8 grid gap-4 md:grid-cols-2">
<Form method="post" className="rounded-lg border p-6">
<h3 className="text-lg font-semibold">Pro — $29/mo</h3>
<p className="mt-2 text-gray-600">Unlimited projects, priority support.</p>
<input type="hidden" name="priceId" value="price_pro_monthly" />
<button type="submit" className="mt-4 rounded bg-black px-4 py-2 text-white">
Upgrade to Pro
</button>
</Form>
</div>
) : (
<p className="mt-4 text-sm text-gray-500">
Renews on {new Date(currentPeriodEnd!).toLocaleDateString()}
</p>
)}
</div>
);
}Tip: Remix actions use standard HTML forms, so billing upgrades work even if JavaScript fails to load.
Tip: Replace 'price_pro_monthly' with your actual Stripe Price ID from the Stripe Dashboard.
Handle Stripe Webhooks
Create a resource route (no UI) that receives Stripe webhook events and updates subscription records in your database. Resource routes in Remix export only a loader or action without a default component, making them perfect for API endpoints like webhooks.
// app/routes/api.webhooks.stripe.ts
import type { ActionFunctionArgs } from "@remix-run/node";
import Stripe from "stripe";
import { prisma } from "~/lib/db.server";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function action({ request }: ActionFunctionArgs) {
const payload = await request.text();
const signature = request.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
payload,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return new Response("Invalid signature", { status: 400 });
}
switch (event.type) {
case "customer.subscription.created":
case "customer.subscription.updated": {
const sub = event.data.object as Stripe.Subscription;
await prisma.subscription.upsert({
where: { stripeSubscriptionId: sub.id },
update: {
status: sub.status,
stripePriceId: sub.items.data[0].price.id,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
},
create: {
stripeSubscriptionId: sub.id,
organizationId: sub.metadata.organizationId!,
status: sub.status,
stripePriceId: sub.items.data[0].price.id,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
},
});
break;
}
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
await prisma.subscription.update({
where: { stripeSubscriptionId: sub.id },
data: { status: "canceled" },
});
break;
}
}
return new Response("OK", { status: 200 });
}Tip: Remix resource routes with dot notation (api.webhooks.stripe) map to /api/webhooks/stripe.
Tip: Always verify the webhook signature to prevent spoofed events.
Deploy to Fly.io or Vercel
Deploy your Remix SaaS to a production environment. Fly.io runs your app as a long-lived server (good for WebSockets and background jobs), while Vercel runs each route as a serverless function. Both work well with Remix — choose based on your architecture needs.
# Option 1: Deploy to Fly.io
npm install -D @flydotio/dockerfile-node
fly launch
fly secrets set DATABASE_URL="..."
fly secrets set STRIPE_SECRET_KEY="..."
fly secrets set SESSION_SECRET="..."
fly deploy
# Option 2: Deploy to Vercel
npm install @vercel/remix
npx vercel --prodTip: Fly.io is better for SaaS apps that need WebSockets, cron jobs, or long-running processes.
Tip: Run database migrations in your deployment pipeline, not manually: 'npx prisma migrate deploy'.
Tip: Set up Stripe webhook endpoints for both staging and production environments.
Next Steps
- →Add team management with invite links and role-based permissions (admin, member, viewer).
- →Implement real-time features using Remix's defer and streaming for live dashboard updates.
- →Add transactional emails for onboarding sequences and billing notifications with Resend.
- →Build an API with resource routes for third-party integrations and mobile apps.