DEV Community

Cover image for Romanian Address Format: The Rules and a React Form
Serban Dan Rusu for Wingo UI

Posted on Originally published at wingo-ui.com

Romanian Address Format: The Rules and a React Form

TL;DR

The Romanian address format starts with the street line (type, name, number, then bl., sc., et. and ap. for block, staircase, floor and apartment), adds the sector in Bucharest, and ends with the 6-digit postal code, the locality and, in domestic use, the county as jud. Cluj. The first two digits of the postal code identify the county, so a form can flag a code from the wrong one. In React, build it as a county select, a locality search that ignores diacritics, street and number, an optional block row and a numeric postal code.

Search for an address form React example and you get a US layout: street, city, state, ZIP. Ship that to customers in Romania and they have nowhere to put the block, the staircase or the Bucharest sector, so the courier calls to ask. This tutorial covers the Romanian address format part by part, checked against the Universal Postal Union's sheet for Romania, with English labels and Romanian data. Then it builds the form in React on Address Input, a component from Wingo UI, the library we build, so we are not neutral.

What is the Romanian address format?

A Romanian address runs from the most specific line to the broadest: the street line with the building details, the sector in Bucharest, then the postal code and the locality. The UPU addressing sheet for Romania (dated October 2007) gives four rules:

  1. The street type comes before the name: Str. (strada), Bd. (bulevardul), Calea, Aleea, Șos. (șoseaua).
  2. The number usually ends the street line.
  3. Building details can go at the end of the same line: block, staircase, floor, apartment.
  4. In Bucharest, the sector can sit on its own line between the street and the locality.

The 6-digit postal code goes to the left of the locality. Here is a Romania address format example from the sheet for an apartment in Bucharest, and one for an office in Craiova:

ASTRA
Drumul Taberei nr. 35, bl. F5, sc. 2, parter, ap. 23
Sector 6
061357 BUCHAREST
ROMANIA

SC PASAJ SA
Str. Bariera Valcii, nr. 215
200716 CRAIOVA
ROMANIA
Enter fullscreen mode Exit fullscreen mode

The sheet writes Valcii for Vâlcii, which matters for search later. Both stop at the locality, since the postal code implies the county. In domestic mail, invoices and courier forms, people add the county (județ) after the locality as jud. Cluj, which also tells apart villages that share a name. Here is the Romanian postal address format as form fields, with English labels:

Form label Romanian Written as
County județ jud. Cluj
Sector sector (București only) Sector 6
City or village municipiu, oraș, comună, sat Cluj-Napoca, sat Vâlcele
Street stradă, bulevard, calea, aleea Str. Memorandumului
No. număr nr. 28
Block bloc bl. A2
Entrance scară sc. 1
Floor etaj et. 3, or parter for the ground floor
Apt. apartament ap. 12
Postal code cod poștal 400114

The labels are Address Input's English defaults. We label the scară "Entrance" because each staircase of a block has its own door, which is what a courier looks for.

How do you write a Bucharest or village address?

In Bucharest the sector takes the place of the county, and in the countryside a village usually needs its commune as well. București is a municipality with the rank of a county, split into six sectors. Written on one line, as a courier form or an invoice prints it, a Bucharest address ends with "Sector 6, 061357 București" and has no jud. part.

Villages add a level. Counties contain municipalities (municipii), towns (orașe) and communes (comune), and a commune groups several villages (sate). The National Institute of Statistics keeps the official list as SIRUTA, a register on those three levels that it publishes as a CSV on data.gov.ro. A village address names the village and usually its commune, as in "sat Vâlcele, com. Feleacu, jud. Cluj": Feleacu is a commune of five villages, one of them Vâlcele. In many rural communities there are no street names at all.

RoAddress, the value type, has no commune field. Keep the village in locality and its SIRUTA code in localityId: the register links every village to its commune, so you can print the commune when a courier needs it.

How do Romanian postal codes relate to counties?

The first two digits of a Romanian postal code identify the county, or the sector in Bucharest. Romania moved to 6-digit codes on May 1, 2003, and the UPU sheet reads them as a postal region, a district of that region (or a sector in Bucharest), then the delivery area. Per Wikipedia's article on postal codes in Romania, cities of more than 50,000 people get a code per street or small group of addresses, a smaller town has one code for the whole town, and village codes have a 7 as their third digit, like Feleacu's 407270. Poșta Română's postal code search finds the full code for an address.

Here is the Romanian counties list with the ISO 3166-2:RO code (the letters on license plates) and the postal prefix. Address Input stores the county as this code, and the prefixes match its RO_POSTAL_PREFIXES:

County Code Postal prefix
Alba AB 51
Arad AR 31
Argeș AG 11
Bacău BC 60
Bihor BH 41
Bistrița-Năsăud BN 42
Botoșani BT 71
Brașov BV 50
Brăila BR 81
București B 01 to 06 (Sector 1 to 6)
Buzău BZ 12
Caraș-Severin CS 32
Călărași CL 91
Cluj CJ 40
Constanța CT 90
Covasna CV 52
Dâmbovița DB 13
Dolj DJ 20
Galați GL 80
Giurgiu GR 08
Gorj GJ 21
Harghita HR 53
Hunedoara HD 33
Ialomița IL 92
Iași IS 70
Ilfov IF 07
Maramureș MM 43
Mehedinți MH 22
Mureș MS 54
Neamț NT 61
Olt OT 23
Prahova PH 10
Satu Mare SM 44
Sălaj SJ 45
Sibiu SB 55
Suceava SV 72
Teleorman TR 14
Timiș TM 30
Tulcea TL 82
Vaslui VS 73
Vâlcea VL 24
Vrancea VN 62

That gives a cheap check. If someone picks Iași and types a code that starts with 40, one of the two is wrong: postalCodeMatchesCounty("400114", "IS") returns false. In Bucharest a chosen sector narrows it to one prefix, so postalCodeMatchesCounty("061357", "B", "6") is true and the same code with sector "3" is false.

Address Input shows a mismatch as an amber warning under the postal code once the field loses focus, and the form stays valid. We chose a warning on purpose: the check only knows prefixes, and a person who knows their own code should never be stuck at checkout.

How do I build a Romanian address form in React?

Split it into a county select, a locality search, street and number, an optional block row and a six-digit postal code, then validate on submit and again on the server. Address Input ships those parts. It is a Pro component, so sign in once with npx wingo-ui@latest login on a Pro account, then run npx wingo-ui@latest add address-input. The CLI adds what it builds on, including ro-locations, text-match, the select and the combobox. The component renders the county, the locality, street and number, a collapsed row for block, staircase, floor and apartment, the postal code and a details field for the intercom or a landmark. Its labels follow the locale (the locale prop, then the provider, then the page language): English, or Romanian for ro.

A checkout step that validates on submit and saves through the Server Action shown further down:

// app/checkout/shipping-step.tsx
"use client";

import { useState } from "react";
import {
  AddressInput,
  EMPTY_RO_ADDRESS,
  validateRoAddress,
  type AddressErrors,
  type RoAddress,
} from "@/components/ui/address-input";
import { Button } from "@/components/ui/button";
import { saveShipping } from "./actions";

export function ShippingStep({ onDone }: { onDone: () => void }) {
  const [address, setAddress] = useState<RoAddress>(EMPTY_RO_ADDRESS);
  const [errors, setErrors] = useState<AddressErrors>();

  return (
    <form
      noValidate
      className="flex flex-col gap-4"
      onSubmit={async (event) => {
        event.preventDefault();
        // the submit already made AddressInput show every error
        if (!validateRoAddress(address).valid) return;
        const result = await saveShipping(address);
        if (result.ok) onDone();
        else setErrors(result.errors);
      }}
    >
      <AddressInput
        label="Delivery address"
        value={address}
        errors={errors}
        onValueChange={(next) => {
          setAddress(next);
          setErrors(undefined);
        }}
        showSummary
      />
      <Button type="submit">Continue to payment</Button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

showSummary prints formatRoAddress under the form, the way the courier will read it: Str. Memorandumului 28, bl. A2, sc. 1, et. 3, ap. 12, 400114 Cluj-Napoca, jud. Cluj.

The parts a hand-rolled form usually misses:

  • Phones. The county and locality lists open as bottom sheets below 768px, the postal code takes six digits with the numeric keypad, and Enter moves to the next part.
  • Autofill. The county uses address-level1, the locality address-level2, the street address-line1 and the code postal-code, so a saved browser address fills the parts.
  • Paste. "Str. Memorandumului nr. 28, bl. A2" pasted into the street fills the street, number and block.
  • County changes. Picking București swaps the locality for a sector select. Changing the county clears the locality and a postal code from another county, and keeps the street and number.

Live demo: Address Input. The original article embeds an interactive demo here (Clear the postal code, type six digits that start with 70, the prefix of Iași, and press Tab: the county is Cluj, so a warning appears while the readout still says valid. Then pick București as the county to get the sector select). Try it live on wingo-ui.com or open the Address Input docs. Install: npx wingo-ui@latest add address-input

How do I search localities without diacritics?

Fold both strings before you compare them: lowercase, decompose with NFD and drop the combining marks. "Iași".includes("iasi") is false, and folded, both sides read iasi. Ș and ț with a comma below only arrived in Unicode 3.0 in 1999, and older encodings had only the cedilla forms ş and ţ, which still turn up in imported data. Both decompose to a base letter plus a combining mark, so NFD folding treats Ștefănești, Ştefăneşti and stefanesti as one name.

text-match does that folding, ranks matches (exact, prefix, word prefix, inside a word) and returns the ranges to highlight, in about 200 lines with no dependencies. ro-locations builds its searchLocations on it, and Address Input's locality combobox filters with it.

Live demo: Romanian locations. The original article embeds an interactive demo here (Type iasi, manastur or sector 3 without diacritics: each finds its place, with the matched letters highlighted). Try it live on wingo-ui.com or open the Romanian locations docs. Install: npx wingo-ui@latest add ro-locations

Folding does not cover abbreviations or old spellings. People type "Tg. Mureș" for Târgu Mureș, and records from before the 1993 spelling rule write î inside words (Tîrgu). filterByText takes keywords next to the label, so aliases handle both:

// lib/locality-search.ts
import { filterByText } from "@/lib/text-match";

// county is the plate code ("CJ"), the code Address Input stores and passes to loadLocalities
export type Locality = { id: string; name: string; county: string; commune?: string };

// how people also write a name: "Tg. Mures", and "Tirgu" from before the 1993 spelling rule
function aliases(name: string) {
  return [name.replace(/^Târgu /, "Tg. "), name.replace(/(?<=\p{L})â(?=\p{L})/gu, "î")];
}

export function searchLocalities(list: readonly Locality[], county: string, query: string) {
  return filterByText(
    list.filter((locality) => locality.county === county),
    query,
    (locality) => [locality.name, ...aliases(locality.name)],
    { limit: 20 },
  );
}
Enter fullscreen mode Exit fullscreen mode

Now "tg mures" and "tirgu mures" both find Târgu Mureș, ranked a little below a direct hit on the name.

How do I load every village from SIRUTA?

Serve the SIRUTA list from your own route and give Address Input a loadLocalities function that queries it. Without one, the component searches a built-in list of 332 cities, towns and large communes and accepts any other locality as typed, with a quiet note under the field. text-match has no "use client" directive, so the same search runs in a route handler:

// app/api/localities/route.ts
import type { NextRequest } from "next/server";
import { LOCALITIES } from "@/data/localities"; // built from the SIRUTA CSV
import { searchLocalities } from "@/lib/locality-search";

export function GET(request: NextRequest) {
  const params = request.nextUrl.searchParams;
  const results = searchLocalities(LOCALITIES, params.get("county") ?? "", params.get("q") ?? "");
  return Response.json(
    results.map(({ item }) => ({
      value: item.id,
      label: item.name,
      // tells apart villages with the same name
      description: item.commune && `com. ${item.commune}`,
    })),
  );
}
Enter fullscreen mode Exit fullscreen mode
<AddressInput
  label="Delivery address"
  loadLocalities={async (county, query, { signal }) => {
    const params = new URLSearchParams({ county, q: query });
    const response = await fetch(`/api/localities?${params}`, { signal });
    return response.ok ? response.json() : [];
  }}
/>
Enter fullscreen mode Exit fullscreen mode

The value you return becomes localityId, so the SIRUTA code is saved with the address.

How do I validate the address on the server?

Check it again in the Server Action, with a zod 4 schema (npm install zod) in a file that has no "use client". Copied component code has a trap here: address-input.tsx starts with "use client", so on the server every export is a client reference. Calling validateRoAddress or formatRoAddress in a Server Component or a Server Action throws "Attempted to call formatRoAddress() from the server but formatRoAddress is on the client". Type imports are fine, and ro-locations has no directive, so the schema can use its county lookup:

// lib/ro-address-schema.ts
import { z } from "zod";
import { getCounty } from "@/lib/ro-locations";

const text = z.string().trim();

export const roAddressSchema = z
  .object({
    county: text.refine((code) => Boolean(getCounty(code)), { error: "Choose a county from the list" }),
    locality: text,
    localityId: text.optional(),
    sector: z.enum(["1", "2", "3", "4", "5", "6"]).optional(),
    street: text.min(1, { error: "Enter the street" }),
    number: text.min(1, { error: "Enter the number" }),
    building: text.optional(),
    entrance: text.optional(),
    floor: text.optional(),
    apartment: text.optional(),
    postalCode: z.union([z.literal(""), z.string().regex(/^\d{6}$/, { error: "A postal code has 6 digits" })]).optional(),
    details: text.optional(),
  })
  // bucharest has sectors where other counties have localities
  .refine((a) => a.county.toUpperCase() !== "B" || Boolean(a.sector), { error: "Choose a sector", path: ["sector"] })
  .refine((a) => a.county.toUpperCase() === "B" || a.locality.length > 0, {
    error: "Enter the city or village",
    path: ["locality"],
  });
Enter fullscreen mode Exit fullscreen mode
// app/checkout/actions.ts
"use server";

import type { AddressErrors } from "@/components/ui/address-input";
import { roAddressSchema } from "@/lib/ro-address-schema";

export async function saveShipping(input: unknown): Promise<{ ok: true } | { ok: false; errors: AddressErrors }> {
  const parsed = roAddressSchema.safeParse(input);
  if (!parsed.success) {
    // the first message per field, the shape of AddressInput's errors prop
    const errors: AddressErrors = {};
    for (const issue of parsed.error.issues) errors[issue.path[0] as keyof AddressErrors] ??= issue.message;
    return { ok: false, errors };
  }
  // save parsed.data with the order
  return { ok: true };
}
Enter fullscreen mode Exit fullscreen mode

Errors from the action land on the matching part through the errors prop and win over the component's own. To format an address on the server for an order email, move formatRoAddress and its helpers into a module without the directive. You own the source.

What should I build next?

Try the postal code check in the demo above, then add the form to your checkout with npx wingo-ui@latest add address-input. Address Input and ro-locations are part of Wingo UI Pro ($8 a month, $80 a year or $150 once as of October 2026), and text-match is free. If the same checkout takes company details, Romanian CUI validation covers tax IDs and the IBAN validation guide covers bank accounts, while React form components explains the label, hint and error anatomy every part of this form shares. More step-by-step React builds live in the Build it category.

Components in this post

  • Address Input (Pro): A Romanian address form: county, locality search, street, number, block and apartment, postal code checked against the county. Install: npx wingo-ui@latest add address-input
  • Romanian locations (Pro): Romania's counties, cities and neighbourhoods with coordinates, diacritic-free search, distances and within-radius lists. Install: npx wingo-ui@latest add ro-locations
  • Text match (Free): Search that ignores case and diacritics, ranks the best matches first and returns the ranges to highlight. Install: npx wingo-ui@latest add text-match

FAQ

How do you write an address in Romania?

Write the street type, name and number first (Str. Memorandumului 28), then the block, staircase, floor and apartment on the same line (bl. A2, sc. 1, et. 3, ap. 12). The next line holds the 6-digit postal code and the locality (400114 Cluj-Napoca), followed in domestic use by the county as jud. Cluj, and in Bucharest a Sector line goes above the postal code. Mail from abroad ends with ROMANIA on its own line.

How many digits does a Romanian postal code have?

Six. Six-digit codes replaced the four-digit ones on May 1, 2003. The first two digits identify the county, and in Bucharest they identify the sector, from 01 for Sector 1 to 06 for Sector 6.

What do bl., sc., et. and ap. mean in a Romanian address?

They abbreviate bloc (the apartment building), scară (the staircase, which has its own entrance), etaj (the floor) and apartament (the apartment). The ground floor is written parter.

How many counties does Romania have?

Romania has 41 counties (județe) plus the municipality of Bucharest, which is divided into six sectors. Their ISO 3166-2:RO codes, such as RO-CJ for Cluj and RO-B for Bucharest, use the same letters as Romanian license plates.

Should a Romanian address form require diacritics?

No. Accept what people type and match it without diacritics: the UPU's own sample addresses for Romania are written without them, and older data uses the cedilla forms ş and ţ. Store the official spelling from your locality list once the person picks a match.

Are Address Input and the Romanian locations data free?

Address Input and ro-locations are part of Wingo UI Pro. text-match, the diacritic-insensitive search both of them use, is free and installs with npx wingo-ui@latest add text-match.


This article was originally published on the Wingo UI blog: Romanian Address Format: The Rules and a React Form. The original has the live, interactive demos and is kept up to date.

Top comments (0)