moon

I'm a full-stack developer from Istanbul, building web and mobile apps with Next.js, React Native, Node.js, and NestJS. I enjoy taking products from idea to production and care about clean code and architecture more than 'it just works'.

Bulut Yerli

projects

watchlabb

Watchlabb Web & Mobile

A watch marketplace where users can list for sale or create real-time auctions for watches. Built solo — web and mobile. Currently serving 4,000+ registered users and generating revenue.

  • Real-time auctions with live bidding and auto-end handling
  • KYC + SMS verification for users and listings
  • Credit card payments & native in-app purchases for mobile
  • Custom-built billing pipeline with cron jobs, processing queues & automated invoicing
  • Auto-generated sale certificates & auto Instagram share cards
  • Push, in-app & email notifications with per-user preferences
  • Role-based access control for admins & moderators
  • typescript
  • next.js
  • react native
  • expo
  • postgresql
  • supabase
  • tailwind
  • unistyles
siryona screenshot

siryona

A mobile application for personalized astrology and birth chart analysis.

Built with Swiss Ephemeris for astronomical calculations and OpenAI for personalized interpretations.

Includes custom authentication, subscriptions, background jobs, and a full backend API.

  • typescript
  • react native
  • expo
  • nestjs
  • postgresql
  • prisma
  • redis
  • bullmq
  • openai
workwise screenshot

workwise

A company management app where HR, managers, and employees each have their own access level and features.

HR manages staff and salaries, managers approve leave requests, employees can submit requests and view the team.

Comes with a financial dashboard, org chart, and a documented REST API.

  • typescript
  • react
  • node.js
  • express
  • postgresql
  • docker
  • jest
nuvola coffee shop screenshot

nuvola coffee shop

A demo coffee shop built to properly implement a full Stripe payment flow — webhooks, order fulfillment, and payment status handling.

AWS Cognito handles auth with email verification, and the cart persists in local storage before sign in.

Wrote a Medium article on the Stripe + Next.js integration.

  • typescript
  • next.js
  • redux
  • stripe
  • scss modules
  • aws

articles

Why I Moved Background Work Out of My API screenshot
August 29, 2026

Why I Moved Background Work Out of My API

A practical look at queues, workers, cron jobs, and independent scaling in NestJS backends. Background jobs are easy to ignore when an application is small. A request comes in, the API does some work, writes to the database, and returns a response. For many applications, that is enough. But eventually, some operations stop fitting comfortably inside an HTTP request. In one of my projects had to perform calculations, generate content with AI, store the results, and prepare some of that content before users even asked for it. Some operations could be triggered directly by users, while others had to run on a schedule. At first, it might seem reasonable to let a cron job handle everything. That approach creates a problem. When a Cron Job Does Too Much? It could look something like this: This is simple, but it makes the cron job responsible for the actual work. Now imagine something fails halfway through the process. Maybe an external API times out. Maybe the AI request fails. Maybe the database connection drops. If it only runs once a day, the next attempt may not happen until tomorrow. That means a temporary failure can turn into a missing result for an entire day. There is another problem: this architecture does not scale particularly well. As the number of users and jobs grows, the cron process becomes responsible for more and more work. I wanted the scheduler to decide when work should happen, not to be responsible for actually doing that work. I ended up separating the backend into three parts: API Cron service Worker service The responsibilities are intentionally simple. The API handles user requests and creates jobs when background processing is required. The cron service only creates scheduled jobs. The worker service performs the actual background work. This separation is the main idea behind the whole architecture. The cron service does not calculate anything. It does not call the AI. It does not process users. It simply puts the required jobs into the queue at the right time. The workers do the actual work. Scaling Workers Independently There was one more reason I wanted the worker service to be completely independent from the API: scaling. The API and background workers have very different workloads. If the number of background jobs increases, adding more API instances does not help process those jobs faster. With the worker service separated, I can increase the number of worker instances independently: All workers consume jobs from the same BullMQ queue through Redis. This allows the workload to be distributed across multiple worker instances without each worker processing the same job. If background processing becomes the bottleneck, I can add more workers without changing the API layer. Likewise, increasing API traffic does not require scaling the workers. The cron service remains independent as well. Its only responsibility is to schedule jobs; the worker pool is responsible for processing them. The Result The final architecture separated three different responsibilities: API → handles requests Cron → schedules jobs Workers → process jobs BullMQ and Redis connect these services while PostgreSQL stores the results. This separation made the system more reliable than having cron jobs perform the work directly, while also allowing the worker layer to scale independently as the workload grows. The main lesson is simple: a scheduler should schedule work, not become the worker. Why I Moved Background Work Out of My API was originally published in Stackademic on Medium, where people are continuing the conversation by highlighting and responding to this story.

Integrating Stripe Payment Elements with Next.js 14 App Router & Webhooks & Typescript screenshot
July 23, 2024

Integrating Stripe Payment Elements with Next.js 14 App Router & Webhooks & Typescript

Introduction: When I wanted to implement Stripe into one of my projects, I found that the official documentation was tailored for the Pages Router. There are many tutorials online about integrating Stripe with Next.js App Router, but most of them either make the entire app client-side or fail to show how to properly handle redirection to a functional success page In this tutorial my main purpose is handling that. My examples might have Postgres codes because I handle products and order database myself. I did not delete them on purpose but you can always implement your own solution. First Thing To Do: You must create a Stripe account and put your keys in an env file. NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="" STRIPE_SECRET_KEY="" STRIPE_SECRET_WEBHOOK_KEY="" Create a Payment Endpoint First, I create a route.ts file in my API folder. In this file, I handle the cart items and calculate the total price using my product database. This calculation must be done server-side to prevent users from manipulating the amount on the client side. Additionally, I ensure that the user is authenticated on the server-side. //src/app/api/payment/route.ts import { db } from '@/src/database'; import { CartItem } from '@/src/types'; import { authenticatedUser } from '@/src/utils/amplify-server-utils'; import { NextRequest, NextResponse } from 'next/server'; import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { typescript: true, }); const calculateOrderAmount = async (items: CartItem[]) => { const productIds = items.map((item) => item.id); const prices = await db .selectFrom('product_variants') .select(['price', 'id']) .where('product_variants.id', 'in', productIds) .execute(); const totalPrice = items.reduce((total, item) => { const product = prices.find((price) => price.id === item.id); return total + (product ? product.price * item.quantity : 0); }, 0); return Math.round(totalPrice * 100); }; export async function POST(request: NextRequest) { try { const { items, address_id, }: { items: CartItem[] | null; address_id: number } = await request.json(); const response = NextResponse.next(); const user = await authenticatedUser({ request, response }); if (!user) { return Response.json({ error: 'Forbidden' }, { status: 403 }); } if (!items) { return Response.json({ error: 'Invalid Items' }, { status: 400 }); } const total = await calculateOrderAmount(items); const paymentIntent = await stripe.paymentIntents.create({ amount: total, currency: 'usd', automatic_payment_methods: { enabled: true, }, metadata: { userId: user.userId, items: JSON.stringify(items), address_id, }, }); return Response.json({ clientSecret: paymentIntent.client_secret, }); } catch (error) { console.log(error); return Response.json({ error: 'Internal server error' }, { status: 500 }); } } Creating Checkout Form First, I create a checkout form that utilizes the PaymentElement from Stripe. This form allows users to enter their card details (or choose other payment options) and complete their payment. The return_url is important here; it will redirect the user to that page upon successful payment. We will create this page later. //src/components/CheckoutForm.tsx 'use client'; import { useState } from 'react'; import styles from './checkoutForm.module.scss'; import { PaymentElement, useElements, useStripe, } from '@stripe/react-stripe-js'; import { StripePaymentElementOptions } from '@stripe/stripe-js'; export default function CheckoutForm() { const stripe = useStripe(); const elements = useElements(); const [message, setMessage] = useState<string | null>(null); const [isLoading, setIsLoading] = useState(false); const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); if (!stripe || !elements) { return; } setIsLoading(true); const { error } = await stripe.confirmPayment({ elements, confirmParams: { return_url: 'http://localhost:3000/checkout/payment-status', }, }); if (error) { if (error.type === 'card_error' || error.type === 'validation_error') { setMessage(error.message || 'An error occurred.'); } else { setMessage('An unexpected error occurred.'); } } setIsLoading(false); }; const paymentElementOptions: StripePaymentElementOptions = { layout: 'tabs', }; return ( <form id="payment-form" className={styles.paymentForm} onSubmit={handleSubmit} > <PaymentElement id="payment-element" options={paymentElementOptions} /> <button className={styles.submitButton} disabled={isLoading || !stripe || !elements} id="submit" > <span id="button-text"> {isLoading ? ( <div className={styles.spinner} id="spinner"></div> ) : ( 'Pay now' )} </span> </button> {message && ( <div id="payment-message" className={styles.paymentMessage}> {message} </div> )} </form> ); } Creating Checkout Page At this stage, we need to create three files. I have created a checkout folder and added a layout.tsx file inside it. I use a layout because we must include the Stripe provider within it. This way, our payment-status page, which users are redirected to from the checkout form, will also be within the same provider. This ensures that the that page can listen to Stripe events and display the right process messages. In the layout.tsx file, I also handle redirecting users to the homepage if there is no clientSecret. This helps ensure that the checkout page is not shown when there are no items ready to buy in the checkout section. //src/app/checkout/layout.tsx 'use client'; import { loadStripe } from '@stripe/stripe-js'; import { Elements } from '@stripe/react-stripe-js'; import { useEffect, useState } from 'react'; import { useSelector } from '@/src/redux/store'; import { selectAddress, setAddresses } from '@/src/redux/slices/addressSlice'; import { useRouter } from 'next/navigation'; loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!) export default function CheckoutLayout({ children, }: { children: React.ReactNode; }) { const [clientSecret, setClientSecret] = useState(''); const { items } = useSelector((state) => state.order); const router = useRouter(); useEffect(() => { fetch('/api/payment', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items }), }) .then((res) => res.json()) .then((data) => { if (data.clientSecret) { setClientSecret(data.clientSecret); } else { router.push('/'); } }); }, []); const options = { clientSecret, }; return ( <main> {clientSecret && ( <Elements key={clientSecret} options={options} stripe={stripePromise}> {children} </Elements> )} </main> ); } At the same level as layout.tsx, I create a page.tsx file. This file will use the CheckoutForm component. //src/checkout/page.tsx 'use client'; import CheckoutForm from '@/src/components/CheckoutForm/CheckoutForm'; import styles from './checkout.module.scss'; export default function CheckoutPage() { return ( <main className={styles.main} color="white"> <h1>Checkout</h1> <div className={styles.checkoutForm}> <CheckoutForm /> </div> </main> ); } Creating Payment Status Page We need to create a page that the checkoutForm will redirect to, as specified in the return_url. Some might think this page is only for showing a success message, but it actually serves several important purposes. You can handle validation errors in the checkoutForm, but other payment issues need to be addressed on this return_url page. Stripe adds extra parameters to the URL when redirecting users, so we use retrievePaymentIntent on this page to check the payment status and display the appropriate messages to the user. This page can also be used to redirect users back to the checkout page, clear the cart if the payment was successful, or handle other tasks based on the payment outcome. //src/app/checkout/payment-status/page.tsx 'use client'; import { useState, useEffect } from 'react'; import { useStripe } from '@stripe/react-stripe-js'; import styles from './complete.module.scss'; import { useDispatch } from '@/src/redux/store'; import { clearCart } from '@/src/redux/slices/cartSlice'; import Container from '@/src/components/Container/Container'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { clearOrder } from '@/src/redux/slices/orderSlice'; export default function PaymentStatusPage() { const [message, setMessage] = useState({ message: '', success: false }); const stripe = useStripe(); const dispatch = useDispatch(); const router = useRouter(); useEffect(() => { if (!stripe) { return; } const clientSecret = new URLSearchParams(window.location.search).get( 'payment_intent_client_secret' ); if (!clientSecret) { router.push('/'); return; } stripe.retrievePaymentIntent(clientSecret).then(({ paymentIntent }) => { if (paymentIntent) { switch (paymentIntent.status) { case 'succeeded': dispatch(clearOrder()); dispatch(clearCart()); setMessage({ message: 'Thank you for your purchase! Your payment was successful', success: true, }); break; case 'processing': setMessage({ message: 'Your payment is currently being processed', success: false, }); break; case 'requires_payment_method': setMessage({ message: 'Unfortunately, your payment could not be completed', success: false, }); router.push('/checkout'); break; default: setMessage({ message: 'An unexpected error occurred. Please contact our support team for assistance.', success: false, }); break; } } }); }, [stripe]); return ( <div className={styles.container}> <h1>{message.message}</h1> {message.success && ( <div className={styles.orderLinkContainer}> <h2>You can check your order and shipping status from here:</h2> <Link href="/account">Go to My Account</Link> </div> )} </div> ); } Creating Webhook Now we need to create a webhook. Webhooks are very important for handling Stripe events in real-time. They manage tasks such as creating new orders, sending confirmation emails to your customers, and more. In this tutorial, I’ll use a webhook to create orders in my database and save purchased items. Note: The webhook retrieves metadata from the payment/route.ts file we created earlier. //src/app/api/webhook/route.ts import Stripe from 'stripe'; import { NextRequest } from 'next/server'; import { headers } from 'next/headers'; import { db } from '@/src/database'; import { CartItem } from '@/src/types'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { typescript: true, }); export async function POST(request: NextRequest) { const body = await request.text(); const endpointSecret = process.env.STRIPE_SECRET_WEBHOOK_KEY!; const sig = headers().get('stripe-signature') as string; let event: Stripe.Event; try { event = stripe.webhooks.constructEvent(body, sig, endpointSecret); } catch (err: any) { console.error(`Webhook Error: ${err.message}`); return Response.json(`Webhook Error: ${err.message}`, { status: 400 }); } switch (event.type) { case 'payment_intent.succeeded': const paymentIntent = event.data.object as Stripe.PaymentIntent; if ( !paymentIntent.metadata || !paymentIntent.metadata.userId || !paymentIntent.metadata.items || !paymentIntent.metadata.address_id ) { console.error('Missing metadata or userId/items/address_id'); return Response.json('Bad Request: Missing metadata', { status: 400 }); } const userId = paymentIntent.metadata.userId; const items: CartItem[] = JSON.parse(paymentIntent.metadata.items); const addressId: number = parseInt(paymentIntent.metadata.address_id, 10); try { await db.transaction().execute(async (trx) => { const order = await trx .insertInto('orders') .values({ user_sub: userId, total_price: paymentIntent.amount_received ? paymentIntent.amount_received / 100 : 0, order_date: new Date(), address_id: addressId, }) .returning('id') .executeTakeFirstOrThrow(); await Promise.all( items.map(async (item) => { return await trx .insertInto('order_items') .values({ order_id: order.id, product_variant_id: item.id, quantity: item.quantity, }) .returningAll() .executeTakeFirst(); }) ); }); console.log('Order and order items successfully inserted.'); } catch (error: any) { console.error(`Database Error: ${error.message}`); return Response.json(`Database Error: ${error.message}`, { status: 500, }); } break; default: console.log(`Unhandled event type ${event.type}`); } return Response.json('Event received', { status: 200 }); } Testing Webhook If you want to test your webhook locally you need to download Stripe CLI and listen your webhook route. Stripe CLI download link: https://docs.stripe.com/stripe-cli After installing Strip CLI, write stripe loginin your terminal. Then paste this: "stripe:listen": "stripe listen --forward-to http://localhost:3000/api/webhook" You must keep this terminal open while testing your app. For production you must visit https://dashboard.stripe.com/webhooks and create a new endpoint for your webapp. All Done! I hope you enjoyed this tutorial and found it helpful for setting up Stripe in your Next.js app. If you have any questions or run into any issues, feel free to reach out to me at bulutyerli.com or leave a comment. Happy coding, and best of luck with your projects! 😊 Stackademic 🎓 Thank you for reading until the end. Before you go: Please consider clapping and following the writer! 👏 Follow us X | LinkedIn | YouTube | Discord Visit our other platforms: In Plain English | CoFeed | Differ More content at Stackademic.com Integrating Stripe Payment Elements with Next.js 14 App Router & Webhooks & Typescript was originally published in Stackademic on Medium, where people are continuing the conversation by highlighting and responding to this story.