Build a Full-Stack Next.js Starter Template with PostgreSQL, Prisma, Material UI, and Auth.js
What You’ll Build
A production-ready Next.js starter template featuring:
- Next.js 15 with App Router and Server Actions
- PostgreSQL database hosted on Railway
- Prisma ORM for type-safe database access
- Auth.js v5 with Credentials authentication (username/password)
- User management: Registration, login, logout, and profile editing
- Item CRUD: Full create, read, update, delete operations
- Material UI: Polished, responsive components
- Dashboard: Stats and recent items overview
- Toast notifications for user feedback
- Pagination for managing items
Prerequisites
Before starting, make sure you have:
- Node.js 18+ installed (download here)
- Git installed and configured
- Git Bash (all codeblocks in this guide are designed to be pasted into a Git Bash terminal)
- GitHub account for code hosting
- Railway account (sign up with our referral link for $20 free credits)
Create Your Next.js Project
Initialize Next.js Application
npx create-next-app@latest next-pg-prisma --typescript --eslint --app --src-dir --import-alias "@/*" --turbopack --react-compiler --no-tailwindThis command creates a new Next.js project with TypeScript, ESLint, App Router, Turbopack, and src/ directory pre-configured.
Navigate to Project Directory
cd next-pg-prismaInstall Dependencies
Install all required packages for the project:
npm install @prisma/client @mui/material @mui/icons-material @emotion/react @emotion/styled @mui/material-nextjs @emotion/cache next-auth@beta pg @prisma/adapter-pg sonnernpm install -D prisma @types/pgInitialize Prisma
npx prisma init --datasource-provider postgresqlThis creates a prisma/schema.prisma file with PostgreSQL configured as the provider, a .env file, and a prisma.config.ts file with environment variable support pre-configured.
Configure Material UI
Next.js App Router requires a specific setup to work with Material UI’s CSS-in-JS engine.
Create Theme Registry
Create src/components/ThemeRegistry/ThemeRegistry.tsx to handle the cache and theme provider:
mkdir -p src/components/ThemeRegistry && cat > src/components/ThemeRegistry/ThemeRegistry.tsx << 'EOF'"use client";
import * as React from "react";import { createTheme, ThemeProvider } from "@mui/material/styles";import CssBaseline from "@mui/material/CssBaseline";import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter";import { Toaster } from "sonner";
import { Roboto } from "next/font/google";
const roboto = Roboto({ weight: ["300", "400", "500", "700"], subsets: ["latin"], display: "swap",});
const theme = createTheme({ typography: { fontFamily: roboto.style.fontFamily, }, palette: { mode: "dark", primary: { main: "#90caf9", }, secondary: { main: "#f48fb1", }, },});
export default function ThemeRegistry({ children }: { children: React.ReactNode }) { return ( <AppRouterCacheProvider> <ThemeProvider theme={theme}> <CssBaseline /> <Toaster position="top-center" richColors /> {children} </ThemeProvider> </AppRouterCacheProvider> );}EOFUpdate Root Layout
Wrap your application with the ThemeRegistry in src/app/layout.tsx:
cat > src/app/layout.tsx << 'EOF'import type { Metadata } from "next";import ThemeRegistry from "@/components/ThemeRegistry/ThemeRegistry";import { NavBar } from "@/components/NavBar";
export const metadata: Metadata = { title: "Next.js + Railway + Auth.js", description: "Full Stack Starter with Auth, Prisma, and MUI",};
export default function RootLayout({ children,}: { children: React.ReactNode;}) { return ( <html lang="en"> <body> <ThemeRegistry> <NavBar /> {children} </ThemeRegistry> </body> </html> );}EOFSet Up Railway and PostgreSQL
Install Railway CLI
npm install -g @railway/cliRestart your terminal after installation.
Login to Railway
railway loginCreate Railway Project
railway initWhen prompted:
- Select a workspace: Choose your workspace
- Project Name: Empty Project for a randomly generated name or
next-pg-prisma
Add PostgreSQL Database
railway add -d postgresThis creates a PostgreSQL database in your Railway project.
Link Your Local Project to Railway
railway linkWhen prompted, make the following selections:
- Select a workspace: Choose your workspace
- Select a project: Choose the project you just created
- Select an environment: Choose
production - Select a service: Choose
Postgres
Note: If you don’t see “Select a service” appear, you may need to run
railway linkagain to ensure the Postgres service is properly linked.
Configure Prisma
Create Prisma Schema
Replace prisma/schema.prisma with example models by running this command (copy/paste entire code block into terminal):
cat > prisma/schema.prisma << 'EOF'generator client { provider = "prisma-client-js" previewFeatures = ["driverAdapters"]}
datasource db { provider = "postgresql"}
model User { id String @id @default(cuid()) name String? email String @unique password String emailVerified DateTime? image String? bio String? items Item[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt}
model Item { id String @id @default(cuid()) name String description String? userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt
@@index([userId])}EOFConfigure Build Script
To ensure Prisma Client is generated during deployment, add a postinstall script to your package.json:
npm pkg set scripts.postinstall="prisma generate"Configure Local Database Connection
Railway’s internal database URL (postgres.railway.internal) isn’t accessible from your local machine. You need to use the public TCP proxy URL instead.
Get your database’s public connection string:
railway variables --service Postgres --json | grep DATABASE_PUBLIC_URLOnce you have the DATABASE_PUBLIC_URL, update your .env file:
cat > .env << 'EOF'DATABASE_URL="<paste your DATABASE_PUBLIC_URL here>?sslmode=require"EOFReplace <paste your DATABASE_PUBLIC_URL here> with the actual URL. It should look like:
DATABASE_URL="postgresql://postgres:password@region.proxy.rlwy.net:PORT/railway"Note: The ?sslmode=require parameter is added in the command above and is required for secure connections.
Generate Migration Files
Now generate the migration files using your local environment variable:
npx prisma migrate dev --name initThis command:
- Uses the
DATABASE_URLyou just set - Creates a
prisma/migrationsdirectory with SQL migration files - Applies the migration to your Railway database
- Generates Prisma Client
Note: If you get a “Can’t reach database server” error (P1001), your Railway PostgreSQL service may have gone to sleep due to inactivity. Wait 10-20 seconds for it to resume, then run the command again.
Create Prisma Client Singleton
Next.js uses hot reloading in development, which can create multiple instances of Prisma Client. This singleton pattern ensures you reuse the same instance across hot reloads, preventing database connection exhaustion.
Create src/lib/prisma.ts:
mkdir -p src/lib && cat > src/lib/prisma.ts << 'EOF'import { Pool } from 'pg'import { PrismaPg } from '@prisma/adapter-pg'import { PrismaClient } from '@prisma/client'
const connectionString = process.env.DATABASE_URL
const pool = new Pool({ connectionString })const adapter = new PrismaPg(pool)
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined}
export const prisma = globalForPrisma.prisma ?? new PrismaClient({ adapter })
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prismaEOFThis file exports a single prisma instance that you’ll import in your API routes and Server Components to query the database.
Configure Authentication (Auth.js)
We’ll use Auth.js v5 (beta) for secure authentication with username/password credentials.
Install Password Hashing Library
npm install bcryptjsnpm install -D @types/bcryptjsCreate Password Utility
Create src/lib/password.ts for password hashing:
mkdir -p src/lib && cat > src/lib/password.ts << 'EOF'import bcrypt from "bcryptjs";
export async function hashPassword(password: string): Promise<string> { return bcrypt.hash(password, 12);}
export async function verifyPassword( password: string, hashedPassword: string): Promise<boolean> { return bcrypt.compare(password, hashedPassword);}EOFConfigure Auth.js
Create the main auth configuration file auth.ts:
cat > src/auth.ts << 'EOF'import NextAuth from "next-auth"import Credentials from "next-auth/providers/credentials"import { prisma } from "@/lib/prisma"import { verifyPassword } from "@/lib/password"
export const { handlers, auth, signIn, signOut } = NextAuth({ providers: [ Credentials({ credentials: { email: { label: "Email", type: "email" }, password: { label: "Password", type: "password" }, }, authorize: async (credentials) => { if (!credentials?.email || !credentials?.password) { return null; }
const user = await prisma.user.findUnique({ where: { email: credentials.email as string }, });
if (!user || !user.password) { return null; }
const isValid = await verifyPassword( credentials.password as string, user.password );
if (!isValid) { return null; }
return { id: user.id, email: user.email, name: user.name, image: user.image, }; }, }), ], session: { strategy: "jwt", }, callbacks: { jwt: ({ token, user }) => { if (user) { token.id = user.id; } return token; }, session: ({ session, token }) => ({ ...session, user: { ...session.user, id: token.id as string, }, }), }, pages: { signIn: "/login", },})EOFCreate TypeScript Type Extensions
Auth.js doesn’t include the user id in the session type by default. Create type extensions to add it:
mkdir -p src/types && cat > src/types/next-auth.d.ts << 'EOF'import { DefaultSession } from "next-auth"
declare module "next-auth" { interface Session { user: { id: string } & DefaultSession["user"] }}EOFThis ensures TypeScript recognizes session.user.id throughout your application.
Create Route Handler
Create the API route to handle auth requests:
mkdir -p src/app/api/auth/[...nextauth] && cat > src/app/api/auth/[...nextauth]/route.ts << 'EOF'import { handlers } from "@/auth"export const { GET, POST } = handlersEOFAdd Proxy for Route Protection
Create src/proxy.ts to protect routes:
cat > src/proxy.ts << 'EOF'export { auth as proxy } from "@/auth"
export const config = { matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],}EOFWhy
proxy.tsinstead ofmiddleware.ts? Railway’s deployment platform uses a custom proxy system for handling Next.js applications. Usingproxy.tswithexport { auth as proxy }is required for Railway deployments. Standard Next.js middleware (middleware.ts) will cause build failures on Railway.
Implement Server Actions
Create Server Actions
Instead of API routes, we’ll use Server Actions to handle data mutations directly from our components.
Create src/app/actions.ts:
cat > src/app/actions.ts << 'EOF'"use server";
import { auth, signIn } from "@/auth";import { prisma } from "@/lib/prisma";import { revalidatePath } from "next/cache";import { hashPassword } from "@/lib/password";import { redirect } from "next/navigation";
export type ActionResult = { success: boolean; error?: string;};
export async function register(formData: FormData): Promise<ActionResult> { try { const name = formData.get("name") as string; const email = formData.get("email") as string; const password = formData.get("password") as string;
if (!name || !email || !password) { return { success: false, error: "All fields are required" }; }
if (password.length < 6) { return { success: false, error: "Password must be at least 6 characters" }; }
const existingUser = await prisma.user.findUnique({ where: { email }, });
if (existingUser) { return { success: false, error: "Email already registered" }; }
const hashedPassword = await hashPassword(password);
await prisma.user.create({ data: { name, email, password: hashedPassword, }, });
return { success: true }; } catch (error) { return { success: false, error: "Failed to create account" }; }}
export async function createItem(formData: FormData): Promise<ActionResult> { try { const session = await auth(); if (!session?.user?.id) { return { success: false, error: "Not authenticated" }; }
const name = formData.get("name") as string; const description = formData.get("description") as string;
if (!name) { return { success: false, error: "Name is required" }; }
await prisma.item.create({ data: { name, description, userId: session.user.id, }, });
revalidatePath("/dashboard"); return { success: true }; } catch (error) { return { success: false, error: "Failed to create item" }; }}
export async function updateItem(formData: FormData): Promise<ActionResult> { try { const session = await auth(); if (!session?.user?.id) { return { success: false, error: "Not authenticated" }; }
const itemId = formData.get("itemId") as string; const name = formData.get("name") as string; const description = formData.get("description") as string;
if (!name) { return { success: false, error: "Name is required" }; }
const item = await prisma.item.findUnique({ where: { id: itemId }, });
if (item?.userId !== session.user.id) { return { success: false, error: "Unauthorized" }; }
await prisma.item.update({ where: { id: itemId }, data: { name, description }, });
revalidatePath("/dashboard"); revalidatePath(`/items/${itemId}`); return { success: true }; } catch (error) { return { success: false, error: "Failed to update item" }; }}
export async function deleteItem(formData: FormData): Promise<ActionResult> { try { const session = await auth(); if (!session?.user?.id) { return { success: false, error: "Not authenticated" }; }
const itemId = formData.get("itemId") as string;
const item = await prisma.item.findUnique({ where: { id: itemId }, });
if (item?.userId !== session.user.id) { return { success: false, error: "Unauthorized" }; }
await prisma.item.delete({ where: { id: itemId }, });
revalidatePath("/dashboard"); return { success: true }; } catch (error) { return { success: false, error: "Failed to delete item" }; }}
export async function updateProfile(formData: FormData): Promise<ActionResult> { try { const session = await auth(); if (!session?.user?.id) { return { success: false, error: "Not authenticated" }; }
const name = formData.get("name") as string; const bio = formData.get("bio") as string;
if (!name) { return { success: false, error: "Name is required" }; }
await prisma.user.update({ where: { id: session.user.id }, data: { name, bio }, });
revalidatePath("/profile"); return { success: true }; } catch (error) { return { success: false, error: "Failed to update profile" }; }}EOFBuild the Application
Create Navigation Bar
Create a shared navigation bar component that appears on all pages:
cat > src/components/NavBar.tsx << 'EOF'import { AppBar, Toolbar, Typography, Button, Box, Avatar } from "@mui/material";import { auth, signOut } from "@/auth";import Link from "next/link";
export async function NavBar() { const session = await auth();
return ( <AppBar position="static" sx={{ mb: 4 }}> <Toolbar> <Link href="/" style={{ textDecoration: "none", color: "inherit", flexGrow: 1 }}> <Typography variant="h6" sx={{ fontWeight: "bold", }} > Next.js Starter </Typography> </Link> <Box sx={{ display: "flex", gap: 2, alignItems: "center" }}> {session ? ( <> <Link href="/dashboard" passHref legacyBehavior> <Button color="inherit"> Dashboard </Button> </Link> <Link href="/profile" passHref legacyBehavior> <Button color="inherit"> Profile </Button> </Link> <Avatar src={session.user.image || undefined} alt={session.user.name || "User"} sx={{ width: 32, height: 32 }} /> <form action={async () => { "use server"; await signOut({ redirectTo: "/" }); }} > <Button color="inherit" type="submit" size="small"> Sign Out </Button> </form> </> ) : ( <> <Link href="/login" passHref legacyBehavior> <Button color="inherit"> Login </Button> </Link> <Link href="/register" passHref legacyBehavior> <Button color="inherit" variant="outlined"> Register </Button> </Link> </> )} </Box> </Toolbar> </AppBar> );}EOFCreate Public Landing Page
Create src/app/page.tsx as a public marketing page:
cat > src/app/page.tsx << 'EOF'import { Container, Typography, Button, Stack } from "@mui/material";import { auth } from "@/auth";import Link from "next/link";import { redirect } from "next/navigation";
export default async function LandingPage() { const session = await auth();
if (session) { redirect("/dashboard"); }
return ( <Container maxWidth="md" sx={{ py: 8, textAlign: "center" }}> <Typography variant="h2" component="h1" gutterBottom fontWeight="bold"> Next.js Full-Stack Starter </Typography> <Typography variant="h5" color="text.secondary" paragraph sx={{ mb: 4 }}> A production-ready foundation with Next.js 15, PostgreSQL, Prisma, Auth.js with Credentials, and Material UI. </Typography>
<Stack direction="row" spacing={2} justifyContent="center" flexWrap="wrap"> <Link href="/register" style={{ textDecoration: "none" }}> <Button variant="contained" size="large"> Get Started </Button> </Link> <Link href="/login" style={{ textDecoration: "none" }}> <Button variant="outlined" size="large"> Login </Button> </Link> </Stack> </Container> );}EOFCreate Registration Page
Create src/app/register/page.tsx for user registration:
mkdir -p src/app/register && cat > src/app/register/page.tsx << 'EOF'import { Container, Typography, TextField, Button, Box, Paper, Alert } from "@mui/material";import { register } from "@/app/actions";import { redirect } from "next/navigation";import Link from "next/link";import { auth } from "@/auth";
export default async function RegisterPage() { const session = await auth(); if (session) { redirect("/dashboard"); }
return ( <Container maxWidth="sm" sx={{ py: 8 }}> <Paper elevation={3} sx={{ p: 4 }}> <Typography variant="h4" component="h1" gutterBottom textAlign="center"> Create Account </Typography> <Box component="form" action={async (formData: FormData) => { "use server"; const result = await register(formData); if (result.success) { redirect("/login?registered=true"); } }} sx={{ mt: 3 }} > <TextField fullWidth label="Name" name="name" required margin="normal" autoComplete="name" /> <TextField fullWidth label="Email" name="email" type="email" required margin="normal" autoComplete="email" /> <TextField fullWidth label="Password" name="password" type="password" required margin="normal" autoComplete="new-password" helperText="At least 6 characters" /> <Button type="submit" fullWidth variant="contained" size="large" sx={{ mt: 3, mb: 2 }} > Register </Button> <Box textAlign="center"> <Link href="/login" style={{ textDecoration: "none" }}> <Typography variant="body2" color="primary"> Already have an account? Login </Typography> </Link> </Box> </Box> </Paper> </Container> );}EOFCreate Login Page
Create src/app/login/page.tsx for user login:
mkdir -p src/app/login && cat > src/app/login/page.tsx << 'EOF'import { Container, Typography, TextField, Button, Box, Paper, Alert } from "@mui/material";import { signIn, auth } from "@/auth";import { redirect } from "next/navigation";import Link from "next/link";
export default async function LoginPage({ searchParams,}: { searchParams: { registered?: string; error?: string };}) { const session = await auth(); if (session) { redirect("/dashboard"); }
return ( <Container maxWidth="sm" sx={{ py: 8 }}> <Paper elevation={3} sx={{ p: 4 }}> <Typography variant="h4" component="h1" gutterBottom textAlign="center"> Login </Typography>
{searchParams.registered && ( <Alert severity="success" sx={{ mt: 2 }}> Account created! Please login. </Alert> )}
{searchParams.error && ( <Alert severity="error" sx={{ mt: 2 }}> Invalid email or password </Alert> )}
<Box component="form" action={async (formData: FormData) => { "use server"; try { await signIn("credentials", { email: formData.get("email"), password: formData.get("password"), redirectTo: "/dashboard", }); } catch (error) { redirect("/login?error=true"); } }} sx={{ mt: 3 }} > <TextField fullWidth label="Email" name="email" type="email" required margin="normal" autoComplete="email" /> <TextField fullWidth label="Password" name="password" type="password" required margin="normal" autoComplete="current-password" /> <Button type="submit" fullWidth variant="contained" size="large" sx={{ mt: 3, mb: 2 }} > Login </Button> <Box textAlign="center"> <Link href="/register" style={{ textDecoration: "none" }}> <Typography variant="body2" color="primary"> Don't have an account? Register </Typography> </Link> </Box> </Box> </Paper> </Container> );}EOFCreate Protected Dashboard
We’ll build a dashboard where users can manage their items. This involves two parts: a client component for interactive actions (edit, delete) and the main dashboard page.
1. Create Item Actions Component
Create src/components/ItemActions.tsx to handle client-side interactions:
First, create a reusable SubmitButton component that shows loading state:
cat > src/components/SubmitButton.tsx << 'EOF'"use client";
import { useFormStatus } from "react-dom";import { Button, CircularProgress } from "@mui/material";import type { ButtonProps } from "@mui/material";
interface SubmitButtonProps extends Omit<ButtonProps, "type"> { children: React.ReactNode; loadingText?: string;}
export function SubmitButton({ children, loadingText = "Loading...", disabled, ...props}: SubmitButtonProps) { const { pending } = useFormStatus();
return ( <Button type="submit" disabled={pending || disabled} {...props} > {pending ? ( <> <CircularProgress size={16} sx={{ mr: 1 }} /> {loadingText} </> ) : children} </Button> );}EOFNow create the ItemActions component with toast notifications:
cat > src/components/ItemActions.tsx << 'EOF'"use client";
import { useState } from "react";import { Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField, Stack, Box,} from "@mui/material";import { Edit, Delete } from "@mui/icons-material";import { toast } from "sonner";import { updateItem, deleteItem } from "@/app/actions";import { SubmitButton } from "./SubmitButton";
interface Item { id: string; name: string; description: string | null;}
export function ItemActions({ item }: { item: Item }) { const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
async function handleUpdate(formData: FormData) { const result = await updateItem(formData); if (result.success) { toast.success("Item updated successfully"); setEditOpen(false); } else { toast.error(result.error || "Failed to update item"); } }
async function handleDelete(formData: FormData) { const result = await deleteItem(formData); if (result.success) { toast.success("Item deleted successfully"); setDeleteOpen(false); } else { toast.error(result.error || "Failed to delete item"); } }
return ( <Box sx={{ display: "flex", gap: 1, alignItems: "center", mt: 2 }}> <IconButton size="small" onClick={() => setEditOpen(true)} aria-label="Edit item" > <Edit fontSize="small" /> </IconButton> <IconButton size="small" color="error" onClick={() => setDeleteOpen(true)} aria-label="Delete item" > <Delete fontSize="small" /> </IconButton>
{/* Edit Dialog */} <Dialog open={editOpen} onClose={() => setEditOpen(false)} maxWidth="sm" fullWidth> <form action={handleUpdate}> <input type="hidden" name="itemId" value={item.id} /> <DialogTitle>Edit Item</DialogTitle> <DialogContent> <Stack spacing={2} sx={{ mt: 1 }}> <TextField name="name" label="Name" defaultValue={item.name} required fullWidth autoFocus inputProps={{ minLength: 1 }} helperText="Required" /> <TextField name="description" label="Description" defaultValue={item.description ?? ""} multiline rows={4} fullWidth helperText="Optional" /> </Stack> </DialogContent> <DialogActions> <Button onClick={() => setEditOpen(false)}> Cancel </Button> <SubmitButton variant="contained" loadingText="Saving..."> Save Changes </SubmitButton> </DialogActions> </form> </Dialog>
{/* Delete Confirmation Dialog */} <Dialog open={deleteOpen} onClose={() => setDeleteOpen(false)}> <form action={handleDelete}> <input type="hidden" name="itemId" value={item.id} /> <DialogTitle>Delete Item?</DialogTitle> <DialogContent> Are you sure you want to delete "{item.name}"? This action cannot be undone. </DialogContent> <DialogActions> <Button onClick={() => setDeleteOpen(false)}> Cancel </Button> <SubmitButton color="error" variant="contained" loadingText="Deleting..."> Delete </SubmitButton> </DialogActions> </form> </Dialog> </Box> );}EOF2. Create Dashboard Page
Create the Dashboard page:
mkdir -p src/app/dashboard && cat > src/app/dashboard/page.tsx << 'EOF'import { prisma } from "@/lib/prisma";import { auth } from "@/auth";import { redirect } from "next/navigation";import { Container, Typography, Box, Card, CardContent, Grid, Paper} from "@mui/material";import { ItemActions } from "@/components/ItemActions";import { CreateItemForm } from "@/components/CreateItemForm";import { Pagination } from "@/components/Pagination";
const ITEMS_PER_PAGE = 10;
interface PageProps { searchParams: Promise<{ page?: string }>;}
export default async function Dashboard({ searchParams }: PageProps) { const session = await auth(); if (!session?.user) { redirect("/login"); }
const params = await searchParams; const currentPage = Math.max(1, parseInt(params.page || "1"));
const [items, totalCount, user] = await Promise.all([ prisma.item.findMany({ where: { userId: session.user.id }, orderBy: { createdAt: "desc" }, skip: (currentPage - 1) * ITEMS_PER_PAGE, take: ITEMS_PER_PAGE, }), prisma.item.count({ where: { userId: session.user.id } }), prisma.user.findUnique({ where: { id: session.user.id }, select: { name: true, email: true }, }), ]);
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
return ( <Container maxWidth="md" sx={{ py: 4 }}> <Box sx={{ mb: 4 }}> <Typography variant="h4" component="h1" fontWeight="bold" gutterBottom> Dashboard </Typography> <Typography variant="body1" color="text.secondary"> Welcome back, {user?.name || "User"}! </Typography> </Box>
<Grid container spacing={3} sx={{ mb: 4 }}> <Grid size={{ xs: 12, sm: 6 }}> <Paper sx={{ p: 3 }}> <Typography variant="h6" color="text.secondary" gutterBottom> Total Items </Typography> <Typography variant="h3" fontWeight="bold"> {totalCount} </Typography> </Paper> </Grid> <Grid size={{ xs: 12, sm: 6 }}> <Paper sx={{ p: 3 }}> <Typography variant="h6" color="text.secondary" gutterBottom> Recent Items </Typography> <Typography variant="h3" fontWeight="bold"> {items.slice(0, 5).length} </Typography> </Paper> </Grid> </Grid>
<Card sx={{ mb: 6, p: 2 }}> <CardContent> <Typography variant="h6" gutterBottom>Create a New Item</Typography> <CreateItemForm /> </CardContent> </Card>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 3 }}> <Typography variant="h5">Your Items</Typography> <Typography variant="body2" color="text.secondary"> {totalCount} {totalCount === 1 ? "item" : "items"} </Typography> </Box>
<Grid container spacing={3}> {items.map((item) => ( <Grid size={12} key={item.id}> <Card> <CardContent> <Typography variant="h6">{item.name}</Typography> <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> {item.description || "No description"} </Typography> <Typography variant="caption" display="block" sx={{ mt: 2, color: "text.disabled" }}> {new Date(item.createdAt).toLocaleDateString()} </Typography> <ItemActions item={item} /> </CardContent> </Card> </Grid> ))} {items.length === 0 && ( <Grid size={12}> <Typography color="text.secondary">No items yet. Create your first one above!</Typography> </Grid> )} </Grid>
{totalPages > 1 && ( <Pagination currentPage={currentPage} totalPages={totalPages} baseUrl="/dashboard" /> )} </Container> );}EOFCreate the CreateItemForm client component with toast feedback:
cat > src/components/CreateItemForm.tsx << 'EOF'"use client";
import { useRef } from "react";import { TextField, Stack } from "@mui/material";import { toast } from "sonner";import { createItem } from "@/app/actions";import { SubmitButton } from "./SubmitButton";
export function CreateItemForm() { const formRef = useRef<HTMLFormElement>(null);
async function handleSubmit(formData: FormData) { const result = await createItem(formData); if (result.success) { toast.success("Item created successfully!"); formRef.current?.reset(); } else { toast.error(result.error || "Failed to create item"); } }
return ( <form ref={formRef} action={handleSubmit}> <Stack spacing={2}> <TextField name="name" label="Name" required fullWidth inputProps={{ minLength: 1 }} helperText="Required" /> <TextField name="description" label="Description" multiline rows={3} fullWidth helperText="Optional" /> <SubmitButton variant="contained" size="large" loadingText="Creating..."> Create Item </SubmitButton> </Stack> </form> );}EOFCreate UI Components
Now let’s create the remaining UI components for the application.
Create Pagination Component
Create a reusable Pagination component for dashboard:
cat > src/components/Pagination.tsx << 'EOF'"use client";
import { Box, Button, Typography } from "@mui/material";import { useRouter, useSearchParams } from "next/navigation";
interface PaginationProps { currentPage: number; totalPages: number; baseUrl: string;}
export function Pagination({ currentPage, totalPages, baseUrl }: PaginationProps) { const router = useRouter(); const searchParams = useSearchParams();
if (totalPages <= 1) return null;
const createPageUrl = (page: number) => { const params = new URLSearchParams(searchParams.toString()); params.set("page", page.toString()); return `${baseUrl}?${params.toString()}`; };
return ( <Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", gap: 2, mt: 4 }}> <Button variant="outlined" disabled={currentPage <= 1} onClick={() => router.push(createPageUrl(currentPage - 1))} > Previous </Button> <Typography> Page {currentPage} of {totalPages} </Typography> <Button variant="outlined" disabled={currentPage >= totalPages} onClick={() => router.push(createPageUrl(currentPage + 1))} > Next </Button> </Box> );}EOFCreate Profile Page
Create src/app/profile/page.tsx for users to view and edit their profile:
mkdir -p src/app/profile && cat > src/app/profile/page.tsx << 'EOF'import { Container, Typography, Paper, Box } from "@mui/material";import { auth } from "@/auth";import { redirect } from "next/navigation";import { prisma } from "@/lib/prisma";import { ProfileForm } from "@/components/ProfileForm";
export default async function ProfilePage() { const session = await auth(); if (!session?.user) { redirect("/login"); }
const user = await prisma.user.findUnique({ where: { id: session.user.id }, select: { name: true, email: true, bio: true, image: true, }, });
if (!user) { redirect("/login"); }
return ( <Container maxWidth="sm" sx={{ py: 4 }}> <Typography variant="h4" component="h1" gutterBottom fontWeight="bold"> Profile </Typography> <Paper elevation={3} sx={{ p: 4, mt: 3 }}> <ProfileForm user={user} /> </Paper> </Container> );}EOFCreate the profile form component:
cat > src/components/ProfileForm.tsx << 'EOF'"use client";
import { useRef } from "react";import { TextField, Stack, Typography, Avatar, Box } from "@mui/material";import { toast } from "sonner";import { updateProfile } from "@/app/actions";import { SubmitButton } from "./SubmitButton";
interface ProfileFormProps { user: { name: string | null; email: string; bio: string | null; image: string | null; };}
export function ProfileForm({ user }: ProfileFormProps) { const formRef = useRef<HTMLFormElement>(null);
async function handleSubmit(formData: FormData) { const result = await updateProfile(formData); if (result.success) { toast.success("Profile updated successfully!"); } else { toast.error(result.error || "Failed to update profile"); } }
return ( <form ref={formRef} action={handleSubmit}> <Stack spacing={3}> <Box sx={{ display: "flex", justifyContent: "center", mb: 2 }}> <Avatar src={user.image || undefined} alt={user.name || "User"} sx={{ width: 100, height: 100 }} /> </Box>
<TextField name="name" label="Name" defaultValue={user.name || ""} required fullWidth />
<TextField label="Email" value={user.email} disabled fullWidth helperText="Email cannot be changed" />
<TextField name="bio" label="Bio" defaultValue={user.bio || ""} multiline rows={4} fullWidth helperText="Tell us about yourself" />
<SubmitButton variant="contained" size="large" loadingText="Saving..."> Save Changes </SubmitButton> </Stack> </form> );}EOFCreate GitHub Repository and Push Code
Install GitHub CLI if you haven’t already. Download and install from:
Restart your terminal after installation.
Authenticate with GitHub:
gh auth loginAdd all changes and make initial commit:
git add .git commit -m "Initial commit: Next.js + PostgreSQL + Prisma"Create GitHub repository and push:
gh repo create --private --source=. --pushPrefer VS Code? Use Source Control panel → Publish to GitHub → Follow prompts
Deploy to Railway
Connect GitHub Repository
In Railway dashboard:
- Click your project
- Click “Create”
- Select “GitHub Repo”
- Choose your repository
- Railway auto-detects Next.js and configures build settings
Link Database to Next.js Service
After connecting your GitHub repository, Railway creates a Next.js service. Now link the database:
- In Railway dashboard, click your Next.js service
- Go to “Variables” tab
- Click “Add Variable” next to where it says “Trying to connect a database?”
- From the list of available variables, select
DATABASE_URLfrom your Postgres service - Click “Add”
Configure Environment Variables
Configure Auth.js environment variables before deploying.
Generate your AUTH_SECRET by running this command in your local project directory:
npx auth secretCopy the output (it will be a long random string).
Go to your Railway dashboard → Variables tab of your Next.js service and add:
| Variable | Value |
|---|---|
AUTH_SECRET | Paste the output from npx auth secret |
AUTH_TRUST_HOST | true |
Why AUTH_TRUST_HOST?
AUTH_TRUST_HOST=truetells Auth.js to trust Railway’s reverse proxy headers, which is required for Railway deployments
Configure Pre-Deploy Command
Railway auto-detects Next.js and handles the build automatically. We need to run database migrations before the application starts.
In Railway dashboard:
- Go to your Next.js service → “Settings” → “Deploy”
- Scroll down to the “Deploy” section
- Click “Add Pre-Deploy step” and enter:
for i in 1 2 3 4 5; do npx prisma migrate deploy && exit 0; sleep $((i * 5)); done; exit 1This command runs before your app starts and:
- Applies all pending migrations to the database
- Automatically retries up to 5 times if the database is starting up (with increasing delays: 5s, 10s, 15s, 20s, 25s)
- Stops deployment if migrations fail after all retries (preventing broken deployments)
Why Pre-Deploy Command? Pre-deploy commands run in a separate container before your app starts, ensuring migrations complete successfully before traffic reaches your application. If migrations fail, the deployment is cancelled automatically.
Why Retry Logic? Railway PostgreSQL services can go to sleep due to inactivity. When they wake up, they may return “the database system is starting up” errors. The retry logic waits for the database to fully wake up before proceeding, preventing deployment failures from transient startup states.
Trigger Initial Deployment
After configuring environment variables and the pre-deploy command, trigger the initial deployment:
- In Railway dashboard, click your Next.js service
- Click the “Deploy” button to start the deployment
- Monitor the deployment progress in the deployment logs
- Wait for the deployment to complete successfully
Generate Domain
After the successful deployment, generate a domain for your application:
- In Railway dashboard, click your Next.js service
- Go to Settings → Networking
- Click “Generate Domain”
- When prompted “Enter the port your app is listening on”, enter
8080 - Railway will generate a domain (e.g.,
your-project-production.up.railway.app)
Your Next.js application is now fully deployed and accessible at your Railway domain!
Next Steps
Build Your Application
Now that you have a working Next.js starter template deployed on Railway, here’s how to build upon it:
-
Replace or Extend the Item Model: The
Itemmodel is intentionally generic as a demonstration. You can:- Keep it and add your own models alongside it
- Rename it to match your domain (requires updating
schema.prisma, running migrations, and updating actions, components, and pages) - Remove it entirely and create your own models from scratch
- Use it as a reference pattern for building your own CRUD features
-
Add Your Own Models: Create new models in
schema.prismaand run migrations -
Customize Components: Modify the Material UI components to match your design
-
Add More Features: File uploads, email integration, roles & permissions, real-time updates, API routes
-
Improve UX: Add form validation (react-hook-form + zod), loading states, error handling, accessibility
-
Security & Performance: Implement rate limiting, input sanitization, caching, monitoring, and testing
Summary
You’ve successfully created a production-ready Next.js starter template with:
- ✅ Next.js 15 with App Router and Server Actions
- ✅ PostgreSQL database on Railway
- ✅ Prisma ORM for type-safe database access
- ✅ Auth.js v5 Credentials authentication
- ✅ User authentication and profile management
- ✅ Item CRUD operations with pagination
- ✅ Material UI for polished, responsive design
- ✅ Dashboard with stats overview
This template provides a solid foundation for building any Next.js application with modern tools and best practices.
Don’t have Railway credits yet? Sign up with our referral link to get $20 in credits - enough for a full month on the Pro tier!