Learniee — Building a Course Discovery Platform Where the Database Does the Work
Learniee — Building a Course Discovery Platform Where the Database Does the Work
Why filtering belongs in SQL, why sessions belong on the server, and what changes when your user is a parent instead of a student.
Learniee is an ed-tech platform where parents search, filter, and book online courses for their children. On the surface it's a search page and a dashboard. Underneath, it's a study in resisting the two most tempting shortcuts in a Next.js app: fetching everything and filtering on the client, and guarding routes in the browser.
The stack is Next.js 14 (App Router) in TypeScript, NextAuth for sessions, Turso/LibSQL as the database, bcryptjs for password hashing, and Tailwind with shadcn/ui for the interface.
The User Isn't the Learner
This shaped every product decision. The person signing up is a parent; the person taking the course is their child. So the account model carries both:
users(id, name, email UNIQUE, password_hash, child_name, child_grade, created_at)
courses(id, title, subject, grade, price, teacher_name, teacher_rating, description, created_at)
child_name and child_grade are captured at signup, not deferred to an onboarding flow, because they're the primary search inputs. A parent whose child is in grade 6 does not want to type "grade 6" into a filter — the platform should already know. Collecting it at registration turns the first dashboard visit into something personalized instead of an empty state.
The trade-off is real: one child per account. A parent with two kids in different grades has no clean path. The correct model is a children table with a foreign key to users, and search scoped by selected child. I shipped the single-child version because it made the entire signup-to-search path exist in one pass, and the migration is additive rather than destructive.
Filtering Belongs in SQL
The search API accepts a lot: keyword, subject, grade, minimum price, maximum price, minimum teacher rating, sort order, and page. The naive implementation fetches all courses once and does the work in React with Array.filter().sort().slice().
I pushed all of it into the query instead. GET /api/courses builds a parameterized SQL statement with a dynamic WHERE clause, applies ORDER BY, and pages with LIMIT 10 OFFSET n, returning { courses, hasMore }.
Three reasons this is the right call even at small scale:
Payload size is bounded by page size, not catalog size. The client receives ten rows regardless of whether the catalog has fifty courses or fifty thousand. Client-side filtering makes the initial download grow with the business.
The database is better at this than JavaScript. Comparison, range filtering, and ordering are what a query planner exists for. Indexes on grade, subject, and price make the filtered query cheap; there is no equivalent to an index in an in-memory array scan.
Parameterized queries close the injection hole by construction. Every filter value arrives as a bound parameter, never string-concatenated into SQL. Building the WHERE clause dynamically while keeping values bound is the pattern worth internalizing: dynamic structure, never dynamic values.
One subtlety that cost me a bug: pagination on a non-unique sort key is unstable. Sorting by price with LIMIT/OFFSET can show the same course twice across pages, or skip one, because rows with identical prices have no guaranteed order between queries. The fix is a deterministic tie-break — every ORDER BY ends with id. Sorting is now total, and page boundaries stop drifting.
Debounce the Filters, Not the Fetch
SearchUI is the one genuinely complex client component: keyword input, subject and grade selects, price range inputs, minimum-rating select, sort select, a clear-all button, a results grid, skeleton loaders, an error state, an empty state, and a Load More button.
Every filter change triggers a request, so requests are debounced by 300ms. That number isn't arbitrary — below roughly 200ms you fire a request per keystroke; above 500ms typing feels disconnected from the results.
Load More appends rather than replaces, driven by hasMore from the API. Appending matters because it preserves scroll position and read state — replacing the grid on page two throws the user back to the top. The state distinction that keeps this honest: filter changes reset to page one and replace results; Load More increments and appends. Conflating those two paths is how you get duplicated cards.
Skeletons instead of spinners, for one reason: a skeleton in the shape of a course card tells the user what's coming and stops the layout from jumping when it arrives.
Sessions on the Server, Not Guards in the Browser
Auth is NextAuth with a credentials provider. authorize() looks up the user by email, compares the submitted password against the bcrypt hash, and returns the user. The jwt callback stamps id, child_name, and child_grade into the token; the session callback surfaces them on session.user. Sessions are JWT-based with a 30-day expiry.
Putting child data in the token is a deliberate optimization: every dashboard render needs it, and reading it from the token avoids a database round trip per request. The cost is staleness — if a parent updates their child's grade, the token holds the old value until it refreshes. For data that changes yearly, that's an acceptable trade. For anything permission-related it absolutely would not be, and that's the line I'd draw for anyone copying the pattern: tokens are for stable identity, not for authorization state that must be revocable.
Protection happens in two layers that both matter:
middleware.tsruns NextAuth'swithAuthover/dashboard/:path*, redirecting unauthenticated requests to/loginbefore any page code executes.- The dashboard layout calls
getServerSession()and redirects if it's missing.
That looks redundant. It isn't. Middleware is the cheap edge check; the server-side session read is the one that actually decides what data gets rendered. And critically, the search API performs its own session check — an endpoint that trusts the middleware to have run is an endpoint that can be called directly.
Signup mirrors this discipline: POST /api/signup validates every field, returns 400 on missing input, 409 on a duplicate email, hashes the password with bcrypt at ten rounds, then inserts. On success the client immediately calls signIn() with the same credentials, so the user lands on the dashboard instead of being told to go log in — a two-line change that removes an entire drop-off point.
Server and Client, Split on Purpose
App Router makes the boundary explicit and I used it as a rule: pages are server components; interaction is client components.
Layouts and dashboard pages are server-rendered — they read the session, redirect if needed, and pass data down. Forms and the search UI are 'use client' because they own local state. AuthProvider exists purely to wrap the client tree in NextAuth's SessionProvider, seeded with the session the server already fetched, so useSession() doesn't refetch what the server just read.
The result is that no page ships a loading spinner for data the server could have resolved, and no interactive component gets accidentally locked into server rendering.
Where the Local Database Fallback Earns Its Keep
lib/db.ts reads TURSO_DATABASE_URL and falls back to a local SQLite file at data/learniee.db when it's absent. Same LibSQL client, same SQL, no branching in application code.
This is the most quietly useful decision in the project. scripts/seed.ts creates the schema and inserts two test parents and roughly fifty courses across all twelve grades — including two courses with deliberately identical price and rating to exercise the tie-break sorting path. So the full flow runs offline, and the pagination edge case has a permanent regression fixture instead of living in my memory.
What's Honestly Missing
Booking. Right now a parent can find the right course, compare teachers, and see the price — and then the flow stops. Payments and enrollment are the next build, and they're the part that turns discovery into a product.
The other gap is supply: courses are seeded, not authored. There's no teacher-facing interface, which means the catalog can't grow without a script. Both are known, both are scoped, and neither changes the architecture — which is roughly the outcome you want from a first pass.