An eight-step tutorial on adding Stripe Tax to a Next.js + Supabase SaaS — the 0.5% fee math, automatic_tax in Checkout, EU reverse charges, and the filing partners that finish the job Stripe Tax doesn’t.
Methodology. This tutorial synthesizes the Stripe Tax documentation as of May 2026. Tax law and pricing change — verify against stripe.com/tax for current details. Statutory thresholds, registration rules, and filing requirements vary by jurisdiction; this guide is a developer’s starting point, not legal or tax advice.
There is a moment in every SaaS’s life when the tax problem stops being a footnote and becomes a real obligation. A French customer signs up and you owe French VAT. Texas hits its economic-nexus threshold and you owe Texas state sales tax. The UK introduces a digital-services rule that catches you flat-footed. By the time you find out, you’re usually six months behind on filings and two letters into a polite-but-firm correspondence with a revenue authority.
Stripe Tax exists to make this part of the payments stack mostly automatic. It calculates the right rate at checkout, applies it correctly for digital goods, handles VAT IDs for B2B reverse charges, and keeps a transaction-level record you can hand to a filing partner at quarter-end. It does not file your returns — that’s the part founders most often miss — but it makes filing a clerical task instead of a forensic one.
This guide walks the canonical eight-step setup, shows the TypeScript that wires automatic_tax into a Stripe Checkout session, covers the gotchas around the 0.5% fee and US economic nexus, and ends with a fair comparison against the Merchant-of-Record alternatives (Lemon Squeezy, Paddle) that handle this problem in a structurally different way.
The bigger question before any code: should you turn Stripe Tax on at all? An honest answer matters because Stripe Tax adds a 0.5% fee on top of the standard 2.9% + $0.30 per transaction, and that fee compounds as you grow.
The pragmatic decision rule, drawn from the public guidance Stripe publishes on jurisdictional thresholds and from the patterns founders post in r/SaaS and Indie Hackers:
The other variable is your geography mix. If 100% of your customers are in countries that don’t tax SaaS (some), Stripe Tax is wasted spend. If your customers span 20 countries, you cannot reasonably calculate VAT/GST/sales-tax by hand even at low MRR — the calculation cost dominates the fee.
The case against Stripe Tax is mostly about the 0.5% fee at scale. At $100K MRR with most revenue passing through Stripe, that’s $500/month for calculation alone. Some founders prefer Anrok or TaxJar’s flat-rate API at that point and disable Stripe’s native calculation. That trade is real, but it’s a Series-A-ish problem; for the first $0–$100K MRR, Stripe Tax is the correct default.
Activation lives in the Dashboard at Settings → Tax. The flow walks you through three things: confirming your origin address (the address that determines your home-country nexus), selecting which jurisdictions you want Stripe to monitor, and choosing the activation date.
The activation date matters because it’s the moment Stripe begins calculating tax on transactions. Set it to today if you’re launching tax collection for the first time. If you’re back-filling for a period when you should have been collecting, the activation date is a flag to your accountant that pre-activation transactions need to be handled separately — usually with a voluntary disclosure agreement in the affected jurisdictions.
Once activated, you can verify the configuration programmatically via the Tax Settings API. The canonical pattern documented in the Stripe API reference:
// scripts/verify-tax-settings.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2026-01-15'
});
async function verify() {
const settings = await stripe.tax.settings.retrieve();
if (settings.status !== 'active') {
throw new Error(
`Stripe Tax is not active: status=${settings.status}`
);
}
console.log('Origin address:', settings.head_office?.address);
console.log('Default tax behavior:', settings.defaults.tax_behavior);
console.log('Default tax code:', settings.defaults.tax_code);
}
verify().catch((err) => {
console.error(err);
process.exit(1);
});
Run this once after activation to confirm the configuration is what you expect. The status field returns active when Stripe Tax is enabled, pending if you started the flow but didn’t finish, and not_collecting otherwise.
Your origin address is the single most important field in Stripe Tax. It’s the address Stripe uses to determine your home jurisdiction, and it’s the anchor point for all subsequent nexus calculations. Set it under Settings → Tax → Locations.
If your business has multiple physical locations — an office in California and a warehouse in Texas, for example — add each one. Each physical-presence location creates an additional nexus that triggers tax-collection obligations in that state regardless of revenue thresholds. For a fully remote, single-founder SaaS, the origin address is your registered business address (typically the LLC’s home state) and that’s the entire list.
Stripe Tax then layers economic nexus on top of physical nexus. Most US states use a threshold like “$100,000 in sales or 200 separate transactions in a calendar year” to trigger an economic-nexus obligation, but the exact numbers vary — California uses $500,000, Texas uses $500,000, and a handful of states use the older 200-transaction rule. Stripe Tax monitors these thresholds and surfaces a registration alert in the Dashboard when you cross one. Registration is still your job; the alert just tells you when.
Every Stripe Product has a tax code that tells Stripe Tax what kind of thing it is. The code drives the rate calculation: SaaS is taxed differently from physical goods, which is taxed differently from a digital download, which is taxed differently from a service. Pick the wrong code and you collect the wrong rate.
For most SaaS, the code is txcd_10000000 — “Software as a Service (SaaS).” Stripe defines this as “cloud-based software, accessed remotely, where the customer does not download or install the software.” That’s the canonical SaaS pattern.
Other codes in the public Stripe tax-code reference that solo founders often need:
txcd_10103000 — Downloadable software (different VAT treatment in some EU countries)txcd_10501000 — Pre-recorded online coursestxcd_10401100 — E-bookstxcd_20030000 — Web hostingtxcd_99999999 — General services (a fallback when nothing else fits)You can search the full list at stripe.com/docs/tax/tax-codes. Set the tax code on the Product itself rather than per-Price — the code travels with the product across all its prices and recurring intervals.
// scripts/seed-products.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2026-01-15'
});
async function seedProduct() {
const product = await stripe.products.create({
name: 'Pro Plan',
tax_code: 'txcd_10000000', // SaaS
metadata: {
tier: 'pro'
}
});
await stripe.prices.create({
product: product.id,
unit_amount: 2900,
currency: 'usd',
recurring: { interval: 'month' },
tax_behavior: 'exclusive' // price excludes tax; see Step 6
});
return product;
}
If you have existing products that pre-date Stripe Tax, update them with stripe.products.update(id, { tax_code: 'txcd_10000000' }) in a one-shot migration script. Products without a tax code default to the account-level default you set in Settings → Tax → Defaults.
Wiring Stripe Tax into a Checkout session is one parameter: automatic_tax: { enabled: true }. Stripe handles the rest — geolocating the customer, applying the right rate, displaying the tax line on the Checkout page, and recording the collected tax on the resulting Invoice.
// app/api/checkout/route.ts
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2026-01-15'
});
export async function POST(req: Request) {
const { priceId, userId, email } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
customer_email: email,
// Stripe Tax: calculate and collect
automatic_tax: { enabled: true },
// Required when automatic_tax is enabled so Stripe
// can geolocate the customer for tax purposes.
customer_update: {
address: 'auto',
name: 'auto'
},
// Collect billing address for tax determination
billing_address_collection: 'required',
// Allow VAT ID entry for B2B reverse charges (see Step 7)
tax_id_collection: { enabled: true },
success_url: `${process.env.APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/pricing`,
metadata: { user_id: userId }
});
return NextResponse.json({ url: session.url });
}
Three required-when-enabled parameters appear above. billing_address_collection: 'required' forces the customer to enter a billing address — Stripe needs the country and (for the US) the postal code to compute the right rate. customer_update: { address: 'auto', name: 'auto' } tells Stripe to write the entered address back onto the Customer object so future invoices can reuse it. tax_id_collection: { enabled: true } shows the optional VAT ID field; if a B2B EU customer enters a valid VAT ID, Stripe applies the reverse charge automatically (Step 7).
For the embedded Checkout pattern (where the form lives inside your own page rather than redirecting to Stripe), the integration is identical — the same parameters apply to checkout.sessions.create regardless of whether you render with <EmbeddedCheckout /> or redirect:
// app/(billing)/checkout/page.tsx
'use client';
import { loadStripe } from '@stripe/stripe-js';
import {
EmbeddedCheckoutProvider,
EmbeddedCheckout
} from '@stripe/react-stripe-js';
import { useCallback } from 'react';
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
);
export default function CheckoutPage({
priceId
}: {
priceId: string;
}) {
const fetchClientSecret = useCallback(async () => {
const res = await fetch('/api/checkout/embedded', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ priceId })
});
const { clientSecret } = await res.json();
return clientSecret;
}, [priceId]);
return (
<EmbeddedCheckoutProvider
stripe={stripePromise}
options={{ fetchClientSecret }}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
);
}
The server side mirrors Step 5’s code with one change: ui_mode: 'embedded' and return_url instead of success_url. The full subscription pattern, including the database side and webhook handling, is in the Stripe subscriptions with Supabase tutorial.
Stripe lets you choose whether a price is tax-inclusive (the headline number already contains tax) or tax-exclusive (tax is added on top). The choice is set per Price via the tax_behavior field, with values inclusive, exclusive, or unspecified.
The convention by market:
tax_behavior: 'exclusive'.tax_behavior: 'inclusive'.tax_behavior: 'exclusive'.The Dashboard exposes this as a “Display tax in prices” toggle under Settings → Tax → Defaults. Setting the default catches new prices created via the Dashboard; for prices created via the API, pass tax_behavior explicitly so you don’t inherit a Dashboard setting that might change later.
Pricing strategy aside, the technical detail to remember is that mixing inclusive and exclusive prices on the same Checkout session is allowed but rarely a good idea — the Checkout page’s tax line displays differently depending on the behavior, and a mixed cart confuses customers. Pick one per market and stick to it.
EU VAT has a special rule for B2B sales: when a VAT-registered business in one EU country sells to a VAT-registered business in another EU country, the seller does not charge VAT. The buyer accounts for VAT on their own return under the “reverse charge” mechanism. This is huge for B2B SaaS because it means a German company buying your tool doesn’t pay VAT to you — they handle it themselves.
Stripe Tax handles reverse charges automatically when three conditions are met: the customer enters a VAT ID at checkout, the VAT ID is valid (Stripe checks against the EU’s VIES database in real time), and the seller and buyer are in different EU countries (or the seller is outside the EU and the buyer is in the EU).
The setup is one parameter on the Checkout session, already shown in Step 5: tax_id_collection: { enabled: true }. With it enabled, the Checkout form shows a “Add VAT ID” field. When the customer fills it in with a valid ID, Stripe validates against VIES, applies reverse charge to the invoice, and writes the VAT ID onto the Customer object for future use.
You can also set tax IDs programmatically when creating customers from your own UI:
// app/api/customer/create/route.ts
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2026-01-15'
});
export async function POST(req: Request) {
const { email, name, address, vatId, vatType } = await req.json();
const customer = await stripe.customers.create({
email,
name,
address // {line1, city, postal_code, state, country}
});
if (vatId) {
// vatType examples: 'eu_vat', 'gb_vat', 'au_abn', 'ca_gst_hst'
await stripe.customers.createTaxId(customer.id, {
type: vatType,
value: vatId
});
}
return NextResponse.json({ customerId: customer.id });
}
Listen for the customer.tax_id.created webhook event to mirror the VAT ID into your own database, since you’ll often want to display it on invoices, gate B2B-only features, or pre-fill the field on subsequent purchases:
// app/api/webhooks/stripe/route.ts (excerpt)
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { sql } from '@/lib/db';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2026-01-15'
});
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return NextResponse.json({ error: 'invalid signature' }, { status: 400 });
}
switch (event.type) {
case 'customer.tax_id.created': {
const taxId = event.data.object as Stripe.TaxId;
await sql`
UPDATE billing_customers
SET vat_id = ${taxId.value},
vat_type = ${taxId.type},
vat_verified = ${taxId.verification?.status === 'verified'}
WHERE stripe_customer_id = ${taxId.customer as string}
`;
break;
}
case 'customer.tax_id.deleted':
case 'customer.tax_id.updated':
// mirror similarly
break;
}
return NextResponse.json({ received: true });
}
The verification.status field is the one that matters. A VAT ID can be entered but unverified (the customer typed it in but VIES rejected it), or verified against the EU registry. Reverse charge applies only to verified IDs — an unverified ID falls back to the standard B2C rate.
The crucial fact about Stripe Tax: it calculates and collects, but it does not file your returns or remit the collected tax to revenue authorities. Filing remains your responsibility. The path forward is one of three:
The combination most solo founders end up with: Stripe Tax + Anrok or TaxJar. Stripe Tax handles the per-transaction math and the VAT-ID validation; the filing partner handles registrations, returns, and the obnoxious paperwork that varies by state. Together they cost roughly 0.5% (Stripe) + ~$200/month (filing partner), which is small relative to the cost of getting a sales-tax audit wrong.
Stripe’s base rate is 2.9% + $0.30 per transaction. With Stripe Tax enabled, the effective rate becomes 3.4% + $0.30. On a $29 monthly subscription, that’s $1.29 in fees instead of $1.14 — a 13% increase in payment costs. Worth running the math: at $10K MRR, Stripe Tax adds $50/month vs roughly $150/month for an accountant to do the same work manually. The break-even is low; the structural answer is “turn it on once you have any international customers.” The full base-rate breakdown is in the Stripe pricing explained guide.
The headline rule of thumb is “$100K or 200 transactions per state, per calendar year,” but the actual numbers and the “or vs and” logic differ. California is $500K, Texas is $500K, New York is $500K and 100 transactions, and several states have dropped the 200-transaction rule entirely. Stripe Tax monitors all of this and alerts you in the Dashboard when you cross a threshold; do not try to track this by hand.
Stripe Tax calculates EU VAT correctly and applies reverse charge to verified B2B sales. But to actually collect EU VAT on B2C sales, you need to register for the One Stop Shop (OSS) scheme in an EU member state — either the country where your business is established, or (for non-EU sellers) the EU member state of your choice. OSS lets you file one quarterly VAT return covering all EU sales instead of registering separately in 27 countries. Stripe Tax doesn’t register you for OSS; you do that through the relevant tax authority’s portal.
The customer.tax_id.created, customer.tax_id.updated, and customer.tax_id.deleted events are part of the standard webhook event set, but you have to subscribe to them explicitly in your webhook endpoint configuration. Add them alongside the invoice.* and customer.subscription.* events you’re already listening to.
Activating Stripe Tax in test mode does not activate it in live mode, and vice versa. Configure both. The product tax codes and tax behaviors are also per-mode, so a product seeded in test mode with the right code will need re-seeding in live mode.
Stripe Tax solves the calculation problem. It does not change the fact that you are the seller of record — the entity legally responsible for collecting, filing, and remitting tax in every jurisdiction where you have nexus. That responsibility is non-trivial.
Lemon Squeezy, Paddle, and (to a lesser extent) FastSpring offer a structurally different deal: they act as the Merchant of Record (MoR), meaning they are the legal seller, they hold nexus in every jurisdiction, and they file and remit. You sell to them at a wholesale rate; they sell to your customer at retail. Tax becomes their problem entirely.
The trade-off:
For most solo founders launching their first paid product, Lemon Squeezy is the lower-friction choice. For founders building a B2B SaaS where customers expect to deal with you directly (procurement, invoicing, custom terms), Stripe + Stripe Tax is the right answer. The full feature comparison is in the Lemon Squeezy vs Stripe breakdown; the Merchant-of-Record concept itself is explained at length in what is a Merchant of Record; the broader payment-processor decision is covered in best payment processor for SaaS.
If your SaaS is a marketplace where one user pays another (think Substack, Gumroad-for-services, or anything involving payouts to creators), Stripe Connect is the underlying primitive and tax becomes more complicated — the platform must decide whether it’s the seller or the marketplace, and Stripe Tax behaves differently in each case. The Stripe Connect tutorial covers the marketplace path.
Stripe Tax turns the most painful part of running a global SaaS into a configuration problem instead of a research problem. The eight steps above are the canonical pattern from the Stripe documentation. The 0.5% fee is the price of not thinking about VAT, and for almost every founder past the first international customer, that’s a trade worth taking. Pair Stripe Tax with a filing partner (TaxJar, Anrok, Numeral) for the part Stripe explicitly does not handle. If you don’t want to deal with any of this, ship on Lemon Squeezy or Paddle and let them be the Merchant of Record.
The stack, prompts, pricing, and mistakes to avoid — for solo founders building with AI.